jsonrpc-rails 0.5.3 → 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: 16369528b9adcf6bc2b66b42365e475be375d330312694626d8244f2205e9df3
4
- data.tar.gz: 4eeda34cff81e732d5aac37d684c81eba9c7a66de1729a7f1f2c1c8097de21fb
3
+ metadata.gz: e098dc20d9c1c05b35e30788afb28d8671fb93be44b4526be851fb9d5800fa80
4
+ data.tar.gz: efa72ded518abf7bec6a360bad11beacde8564d426fb07829c32d1a0e390a7f3
5
5
  SHA512:
6
- metadata.gz: ebefd2f90219d0dd655a9e7fc21023aa38b26de412e52102bdb6d2014a75f1975c284a417d2f353989e09360c34b56c07e42af89643e99bb3fe990db886a1039
7
- data.tar.gz: 2c447976bc3b1dc7a8e9c2ffc695278bd7ec0e44bbad864f5810c16adefb901ec1212145a8d56865e4f7a59eba36b760db71e69addac2b70422f2b8276b53a7f
6
+ metadata.gz: a3521b7da4cd23cda88c2bde3ccbd5dc39077583f3aef93da259a865df80997f6699cd39006e5abcbd34312b55f9092b59bc148bc0e20a075310afa2ba7dd5d4
7
+ data.tar.gz: f42cd489ee6c10e279de3db87dcd849d0f12f8476e4535146b302f26308a1b503ae09fba4e75f9aa35dd0e9642fceb0e174ec3871b53740f5365287e62162fd6
data/README.md CHANGED
@@ -54,6 +54,30 @@ config.jsonrpc_rails.validated_paths = [
54
54
  Leave the array empty (default) and the middleware is effectively off.
55
55
  Use [/.*\z/] if you really want it on everywhere.
56
56
 
57
+ Protocol integrations can reuse the parsing, JSON-RPC validation, typed-object
58
+ conversion, and error handling while adding their own envelope rules:
59
+
60
+ ```ruby
61
+ config.middleware.use JSONRPC_Rails::Middleware::Validator,
62
+ [ "/mcp" ],
63
+ payload_validator: MyProtocolValidator,
64
+ batch_policy: :reject
65
+ ```
66
+
67
+ `payload_validator` may implement `valid?(payload)` or `call(payload)` and receives
68
+ the decoded Hash or Array after generic JSON-RPC validation. Batches are allowed by
69
+ default; `batch_policy: :reject` disables them.
70
+
71
+ By default, matching POST requests without an `application/json` content type pass
72
+ through untouched. Protocol endpoints that accept only JSON-RPC can reject those
73
+ requests with HTTP 415 instead:
74
+
75
+ ```ruby
76
+ config.middleware.use JSONRPC_Rails::Middleware::Validator,
77
+ [ "/rpc" ],
78
+ require_json_content_type: true
79
+ ```
80
+
57
81
  In your controllers, you can render JSON-RPC responses like so:
58
82
 
59
83
  ```ruby
@@ -100,8 +124,8 @@ You can override the default `message` or add `data` for either method by provid
100
124
  The gem automatically inserts `JSONRPC_Rails::Middleware::Validator` into your application's middleware stack. This middleware performs the following actions for incoming **POST** requests with `Content-Type: application/json`:
101
125
 
102
126
  1. **Parses** the JSON body. Returns a JSON-RPC `Parse error (-32700)` if parsing fails.
103
- 2. **Validates** the structure against the JSON-RPC 2.0 specification (single or batch). It performs strict validation, ensuring `jsonrpc: "2.0"`, a string `method`, optional `params` (array/object), optional `id` (string/number/null), and **no extraneous keys**. Returns a JSON-RPC `Invalid Request (-32600)` error if validation fails. **Note:** For batch requests, if *any* individual request within the batch is structurally invalid, the entire batch is rejected with a single `Invalid Request (-32600)` error.
104
- 3. **Stores** the validated, parsed payload (the original Ruby Hash or Array) in `request.env[:jsonrpc]` if validation succeeds.
127
+ 2. **Validates** request, notification, and response structures against JSON-RPC 2.0, including single and batch payloads. Extension members are accepted, while conflicting reserved envelope members are rejected. Returns a JSON-RPC `Invalid Request (-32600)` error if validation fails. **Note:** For batch payloads, if *any* message is structurally invalid, the entire batch is rejected with a single `Invalid Request (-32600)` error.
128
+ 3. **Stores** the validated payload as a typed `JSON_RPC::Request`, `JSON_RPC::Notification`, or `JSON_RPC::Response` (or an Array of those objects) in `request.env[:jsonrpc]`.
105
129
  4. **Passes** the request to the next middleware or your controller action.
106
130
 
107
131
  In your controller action, you can access the validated payload like this:
@@ -184,6 +208,58 @@ class MyApiController < ApplicationController
184
208
  end
185
209
  ```
186
210
 
211
+ ## Error Codes
212
+
213
+ The gem implements the complete JSON-RPC 2.0 error specification. All error codes are defined in `lib/json_rpc/json_rpc_error.rb` and can be used either as numeric codes or symbols:
214
+
215
+ | Code | Symbol | Name | Description |
216
+ |------|--------|------|-------------|
217
+ | -32700 | `:parse_error` | Parse Error | Invalid JSON was received by the server |
218
+ | -32600 | `:invalid_request` | Invalid Request | The JSON sent is not a valid Request object |
219
+ | -32601 | `:method_not_found` | Method Not Found | The method does not exist / is not available |
220
+ | -32602 | `:invalid_params` | Invalid Params | Invalid method parameter(s) |
221
+ | -32603 | `:internal_error` | Internal Error | Internal JSON-RPC error |
222
+ | -32000 | `:server_error` | Server Error | Implementation-defined server error |
223
+
224
+ ### Usage Examples
225
+
226
+ ```ruby
227
+ # Using numeric codes
228
+ render jsonrpc: {}, error: -32601, id: 1
229
+
230
+ # Using symbols
231
+ render jsonrpc: {}, error: :method_not_found, id: 1
232
+
233
+ # Creating error objects
234
+ error = JSON_RPC::JsonRpcError.build(:invalid_params)
235
+ render jsonrpc: JSON_RPC::Response.new(id: 1, error: error)
236
+ ```
237
+
238
+ ## JSONRPC Namespace
239
+
240
+ The gem uses two main namespaces to separate core JSON-RPC functionality from Rails integration:
241
+
242
+ ### JSON_RPC Module (Core Specification)
243
+
244
+ Contains the core JSON-RPC 2.0 specification objects:
245
+
246
+ - **`JSON_RPC::Request`** - Represents a JSON-RPC request with method, params, and id
247
+ - **`JSON_RPC::Response`** - Represents a JSON-RPC response with result or error
248
+ - **`JSON_RPC::Notification`** - Represents a JSON-RPC notification (request without id)
249
+ - **`JSON_RPC::JsonRpcError`** - Exception class for JSON-RPC errors with standard error codes
250
+ - **`JSON_RPC::Parser`** - Utility for converting hashes to typed JSON-RPC objects
251
+
252
+ ### JSONRPC_Rails Module (Rails Integration)
253
+
254
+ Contains Rails-specific integration components:
255
+
256
+ - **`JSONRPC_Rails::Railtie`** - Rails initialization, middleware setup, and renderer registration
257
+ - **`JSONRPC_Rails::Middleware::Validator`** - Rack middleware for validating JSON-RPC requests
258
+ - **`JSONRPC_Rails::ControllerHelpers`** - Helper methods like `jsonrpc_params` and `jsonrpc_params_batch?`
259
+ - **`JSONRPC_Rails::VERSION`** - Current gem version
260
+
261
+ This separation ensures clean architecture where core JSON-RPC logic is independent of Rails, while Rails-specific features are properly isolated.
262
+
187
263
  ## Testing
188
264
 
189
265
  A dummy Rails application is included within the gem (located in `test/dummy`) to facilitate testing. You can run the tests from the **project root directory** by executing:
@@ -2,6 +2,8 @@
2
2
 
3
3
  require "json"
4
4
  require "active_support/json"
5
+ require "rack/media_type"
6
+ require "stringio"
5
7
 
6
8
  module JSONRPC_Rails
7
9
  module Middleware
@@ -14,54 +16,75 @@ module JSONRPC_Rails
14
16
  # IDs or empty batches no longer raise before we can return a proper
15
17
  # -32600 “Invalid Request”.
16
18
  class Validator
17
- CONTENT_TYPE = "application/json"
18
- ENV_PAYLOAD_KEY = :jsonrpc
19
+ CONTENT_TYPE = "application/json"
20
+ ENV_PAYLOAD_KEY = :jsonrpc
21
+ BATCH_POLICIES = %i[allow reject].freeze
22
+ RESERVED_MEMBERS = %w[jsonrpc method params id result error].freeze
19
23
 
20
24
  # @param app [#call]
21
25
  # @param paths [Array<String, Regexp, Proc>] paths to validate
22
- def initialize(app, paths = nil)
23
- @app = app
26
+ # @param payload_validator [#valid?, #call, nil] optional protocol validator
27
+ # @param batch_policy [Symbol] either :allow or :reject
28
+ # @param require_json_content_type [Boolean] reject matching POST requests
29
+ # that do not use application/json instead of passing them downstream
30
+ def initialize(app, paths = nil, payload_validator: nil, batch_policy: :allow,
31
+ require_json_content_type: false)
32
+ unless BATCH_POLICIES.include?(batch_policy)
33
+ raise ArgumentError, "batch_policy must be one of: #{BATCH_POLICIES.join(", ")}"
34
+ end
35
+
36
+ @app = app
24
37
  @paths = Array(paths || Rails.configuration.jsonrpc_rails.validated_paths)
38
+ @payload_validator = payload_validator
39
+ @batch_policy = batch_policy
40
+ @require_json_content_type = require_json_content_type
25
41
  end
26
42
 
27
43
  # Rack entry point
28
44
  def call(env)
29
45
  return @app.call(env) unless validate_path?(env["PATH_INFO"])
30
- return @app.call(env) unless env["REQUEST_METHOD"] == "POST" &&
31
- env["CONTENT_TYPE"]&.start_with?(CONTENT_TYPE)
46
+ return @app.call(env) unless env["REQUEST_METHOD"] == "POST"
32
47
 
33
- body = env["rack.input"].read
34
- env["rack.input"].rewind
48
+ unless json_content_type?(env["CONTENT_TYPE"])
49
+ return @app.call(env) unless @require_json_content_type
35
50
 
36
- raw_payload = parse_json(body)
51
+ return jsonrpc_error_response(
52
+ :server_error,
53
+ status: 415,
54
+ message: "Unsupported Media Type: Content-Type must be application/json"
55
+ )
56
+ end
37
57
 
38
- unless raw_payload.is_a?(Hash) || raw_payload.is_a?(Array)
39
- id = extract_id_from_raw_payload(raw_payload)
40
- return jsonrpc_error_response(:invalid_request, id: id)
58
+ body = env["rack.input"].read
59
+ # Replace consumed input with fresh StringIO for downstream middleware
60
+ # This is Rack 3.0+ compatible and works with all input stream types
61
+ env["rack.input"] = StringIO.new(body)
62
+
63
+ begin
64
+ raw_payload = ActiveSupport::JSON.decode(body)
65
+ rescue ActiveSupport::JSON.parse_error
66
+ return jsonrpc_error_response(:parse_error)
41
67
  end
42
68
 
43
- validity, = if raw_payload.is_a?(Array)
44
- validate_batch(raw_payload)
45
- else
46
- validate_single(raw_payload)
69
+ unless raw_payload.is_a?(Hash) || raw_payload.is_a?(Array)
70
+ return jsonrpc_error_response(:invalid_request)
47
71
  end
48
- return validity unless validity == :valid
49
72
 
50
- env[ENV_PAYLOAD_KEY] = convert_to_objects(raw_payload)
73
+ return invalid_request_response(raw_payload) unless valid_batch_policy?(raw_payload)
74
+ return invalid_request_response(raw_payload) unless valid_payload_structure?(raw_payload)
75
+ return invalid_request_response(raw_payload) unless valid_protocol_payload?(raw_payload)
76
+
77
+ begin
78
+ env[ENV_PAYLOAD_KEY] = convert_to_objects(raw_payload)
79
+ rescue JSON_RPC::JsonRpcError, ArgumentError, TypeError
80
+ return invalid_request_response(raw_payload)
81
+ end
51
82
 
52
83
  @app.call(env)
53
84
  end
54
85
 
55
86
  private
56
87
 
57
- def parse_json(body)
58
- return nil if body.nil? || body.strip.empty?
59
-
60
- ActiveSupport::JSON.decode(body)
61
- rescue ActiveSupport::JSON.parse_error
62
- nil
63
- end
64
-
65
88
  def validate_path?(path)
66
89
  return false if @paths.empty?
67
90
 
@@ -75,35 +98,86 @@ module JSONRPC_Rails
75
98
  end
76
99
  end
77
100
 
101
+ def json_content_type?(content_type)
102
+ media_type = Rack::MediaType.type(content_type)&.strip
103
+ media_type&.casecmp?(CONTENT_TYPE) || false
104
+ end
105
+
78
106
  # -------------------- structure validation --------------------------------
79
107
 
80
- def validate_single_structure(obj)
108
+ def valid_payload_structure?(payload)
109
+ if payload.is_a?(Array)
110
+ !payload.empty? && payload.all? { |message| valid_message_structure?(message) }
111
+ else
112
+ valid_message_structure?(payload)
113
+ end
114
+ end
115
+
116
+ def valid_message_structure?(obj)
81
117
  return false unless obj.is_a?(Hash)
82
118
  return false unless obj["jsonrpc"] == "2.0"
83
- return false unless obj["method"].is_a?(String)
84
119
 
85
- return false if obj.key?("params") && !obj["params"].is_a?(Array) && !obj["params"].is_a?(Hash)
120
+ if obj.key?("method")
121
+ valid_call_structure?(obj)
122
+ elsif obj.key?("result") || obj.key?("error")
123
+ valid_response_structure?(obj)
124
+ else
125
+ false
126
+ end
127
+ end
86
128
 
87
- return false if obj.key?("id") && ![ String, Integer, NilClass ].include?(obj["id"].class)
129
+ def valid_call_structure?(obj)
130
+ return false unless obj["method"].is_a?(String)
131
+ return false unless valid_params?(obj)
132
+ return false if obj.key?("id") && !valid_id?(obj["id"])
88
133
 
89
- allowed = %w[jsonrpc method params id]
90
- (obj.keys - allowed).empty?
134
+ no_conflicting_members?(obj, %w[jsonrpc method params id])
91
135
  end
92
136
 
93
- def validate_single(obj)
94
- if validate_single_structure(obj)
95
- [ :valid, obj["id"] ]
137
+ def valid_response_structure?(obj)
138
+ return false unless obj.key?("id") && valid_id?(obj["id"])
139
+ return false if obj.key?("result") == obj.key?("error")
140
+
141
+ if obj.key?("result")
142
+ no_conflicting_members?(obj, %w[jsonrpc id result])
96
143
  else
97
- id = obj.is_a?(Hash) ? obj["id"] : nil
98
- [ jsonrpc_error_response(:invalid_request, id: id), id ]
144
+ valid_error_structure?(obj["error"]) &&
145
+ no_conflicting_members?(obj, %w[jsonrpc id error])
99
146
  end
100
147
  end
101
148
 
102
- def validate_batch(batch)
103
- return [ jsonrpc_error_response(:invalid_request), nil ] unless batch.is_a?(Array) && !batch.empty?
149
+ def valid_error_structure?(error)
150
+ return false unless error.is_a?(Hash)
151
+ return false unless error["code"].is_a?(Integer)
152
+ return false unless error["message"].is_a?(String)
153
+
154
+ true
155
+ end
156
+
157
+ def no_conflicting_members?(obj, allowed_members)
158
+ (obj.keys & (RESERVED_MEMBERS - allowed_members)).empty?
159
+ end
160
+
161
+ def valid_params?(obj)
162
+ !obj.key?("params") || obj["params"].is_a?(Array) || obj["params"].is_a?(Hash)
163
+ end
164
+
165
+ def valid_id?(id)
166
+ id.is_a?(String) || id.is_a?(Numeric) || id.nil?
167
+ end
168
+
169
+ def valid_batch_policy?(payload)
170
+ !payload.is_a?(Array) || @batch_policy == :allow
171
+ end
172
+
173
+ def valid_protocol_payload?(payload)
174
+ return true unless @payload_validator
104
175
 
105
- invalid = batch.find { |el| !validate_single_structure(el) }
106
- invalid ? [ jsonrpc_error_response(:invalid_request), nil ] : [ :valid, nil ]
176
+ if @payload_validator.respond_to?(:valid?)
177
+ @payload_validator.valid?(payload)
178
+ else
179
+ @payload_validator.call(payload)
180
+ end
107
181
  end
108
182
 
109
183
  # ------------------ conversion to typed objects ---------------------------
@@ -123,21 +197,26 @@ module JSONRPC_Rails
123
197
 
124
198
  # @param error_sym [Symbol]
125
199
  # @param status [Integer]
126
- # @param id [String, Integer, nil]
200
+ # @param id [String, Numeric, nil]
201
+ # @param message [String, nil]
127
202
  # @return [Array] Rack triplet
128
- def jsonrpc_error_response(error_sym, status: 400, id: nil)
129
- error_obj = JSON_RPC::JsonRpcError.build(error_sym)
203
+ def jsonrpc_error_response(error_sym, status: 400, id: nil, message: nil)
204
+ error_obj = JSON_RPC::JsonRpcError.build(error_sym, message: message)
130
205
  payload = JSON_RPC::Response.new(id: id, error: error_obj).to_json
131
206
 
132
- [ status, { "Content-Type" => CONTENT_TYPE }, [ payload ] ]
207
+ [ status, { "content-type" => CONTENT_TYPE }, [ payload ] ]
208
+ end
209
+
210
+ def invalid_request_response(raw_payload)
211
+ jsonrpc_error_response(:invalid_request, id: extract_id_from_raw_payload(raw_payload))
133
212
  end
134
213
 
135
214
  # Extract ID from raw payload if possible
136
215
  def extract_id_from_raw_payload(raw)
137
216
  return nil unless raw.is_a?(Hash)
138
- raw["id"]
139
- rescue
140
- nil
217
+
218
+ id = raw["id"]
219
+ id if valid_id?(id)
141
220
  end
142
221
  end
143
222
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module JSONRPC_Rails
4
- VERSION = "0.5.3"
4
+ VERSION = "0.6.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: jsonrpc-rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.3
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Abdelkader Boudih
@@ -67,7 +67,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
67
67
  - !ruby/object:Gem::Version
68
68
  version: '0'
69
69
  requirements: []
70
- rubygems_version: 3.6.9
70
+ rubygems_version: 4.0.10
71
71
  specification_version: 4
72
72
  summary: A Railtie-based gem that brings JSON-RPC 2.0 support to your Rails application.
73
73
  test_files: []