jsonrpc-rails 0.5.4 → 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: 3c170f3b4c1b8b5d8903fc6202f317d98340c0939dae84117c062009f4128de3
4
- data.tar.gz: 047dfecbe033df5e49a41a03d4c471452953358cb41bc95c0e2add3b9666ff81
3
+ metadata.gz: e098dc20d9c1c05b35e30788afb28d8671fb93be44b4526be851fb9d5800fa80
4
+ data.tar.gz: efa72ded518abf7bec6a360bad11beacde8564d426fb07829c32d1a0e390a7f3
5
5
  SHA512:
6
- metadata.gz: 2346088ba624f426d7f26df3f1d3d46faec883d2b45ef929ccd53e4bb1085132e8c0d2491b0f322ade4592ef0ffc182b0e39f15c795a490c7f0e4d2a2a646de1
7
- data.tar.gz: c76d9df6e6b1e5c7eab1498ac9cf4a544f00c796cab13ae487ff302ac77a500ed488bd920ce9f010391de96430ee888fa68d3388f898646ebd82045e9d919dfd
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:
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "json"
4
4
  require "active_support/json"
5
+ require "rack/media_type"
5
6
  require "stringio"
6
7
 
7
8
  module JSONRPC_Rails
@@ -15,56 +16,75 @@ module JSONRPC_Rails
15
16
  # IDs or empty batches no longer raise before we can return a proper
16
17
  # -32600 “Invalid Request”.
17
18
  class Validator
18
- CONTENT_TYPE = "application/json"
19
- 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
20
23
 
21
24
  # @param app [#call]
22
25
  # @param paths [Array<String, Regexp, Proc>] paths to validate
23
- def initialize(app, paths = nil)
24
- @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
25
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
26
41
  end
27
42
 
28
43
  # Rack entry point
29
44
  def call(env)
30
45
  return @app.call(env) unless validate_path?(env["PATH_INFO"])
31
- return @app.call(env) unless env["REQUEST_METHOD"] == "POST" &&
32
- env["CONTENT_TYPE"]&.start_with?(CONTENT_TYPE)
46
+ return @app.call(env) unless env["REQUEST_METHOD"] == "POST"
47
+
48
+ unless json_content_type?(env["CONTENT_TYPE"])
49
+ return @app.call(env) unless @require_json_content_type
50
+
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
33
57
 
34
58
  body = env["rack.input"].read
35
59
  # Replace consumed input with fresh StringIO for downstream middleware
36
60
  # This is Rack 3.0+ compatible and works with all input stream types
37
61
  env["rack.input"] = StringIO.new(body)
38
62
 
39
- raw_payload = parse_json(body)
63
+ begin
64
+ raw_payload = ActiveSupport::JSON.decode(body)
65
+ rescue ActiveSupport::JSON.parse_error
66
+ return jsonrpc_error_response(:parse_error)
67
+ end
40
68
 
41
69
  unless raw_payload.is_a?(Hash) || raw_payload.is_a?(Array)
42
- id = extract_id_from_raw_payload(raw_payload)
43
- return jsonrpc_error_response(:invalid_request, id: id)
70
+ return jsonrpc_error_response(:invalid_request)
44
71
  end
45
72
 
46
- validity, = if raw_payload.is_a?(Array)
47
- validate_batch(raw_payload)
48
- else
49
- validate_single(raw_payload)
50
- end
51
- return validity unless validity == :valid
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)
52
76
 
53
- env[ENV_PAYLOAD_KEY] = convert_to_objects(raw_payload)
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
54
82
 
55
83
  @app.call(env)
56
84
  end
57
85
 
58
86
  private
59
87
 
60
- def parse_json(body)
61
- return nil if body.nil? || body.strip.empty?
62
-
63
- ActiveSupport::JSON.decode(body)
64
- rescue ActiveSupport::JSON.parse_error
65
- nil
66
- end
67
-
68
88
  def validate_path?(path)
69
89
  return false if @paths.empty?
70
90
 
@@ -78,35 +98,86 @@ module JSONRPC_Rails
78
98
  end
79
99
  end
80
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
+
81
106
  # -------------------- structure validation --------------------------------
82
107
 
83
- 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)
84
117
  return false unless obj.is_a?(Hash)
85
118
  return false unless obj["jsonrpc"] == "2.0"
86
- return false unless obj["method"].is_a?(String)
87
119
 
88
- 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
89
128
 
90
- 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"])
91
133
 
92
- allowed = %w[jsonrpc method params id]
93
- (obj.keys - allowed).empty?
134
+ no_conflicting_members?(obj, %w[jsonrpc method params id])
94
135
  end
95
136
 
96
- def validate_single(obj)
97
- if validate_single_structure(obj)
98
- [ :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])
99
143
  else
100
- id = obj.is_a?(Hash) ? obj["id"] : nil
101
- [ jsonrpc_error_response(:invalid_request, id: id), id ]
144
+ valid_error_structure?(obj["error"]) &&
145
+ no_conflicting_members?(obj, %w[jsonrpc id error])
102
146
  end
103
147
  end
104
148
 
105
- def validate_batch(batch)
106
- 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
107
175
 
108
- invalid = batch.find { |el| !validate_single_structure(el) }
109
- 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
110
181
  end
111
182
 
112
183
  # ------------------ conversion to typed objects ---------------------------
@@ -126,21 +197,26 @@ module JSONRPC_Rails
126
197
 
127
198
  # @param error_sym [Symbol]
128
199
  # @param status [Integer]
129
- # @param id [String, Integer, nil]
200
+ # @param id [String, Numeric, nil]
201
+ # @param message [String, nil]
130
202
  # @return [Array] Rack triplet
131
- def jsonrpc_error_response(error_sym, status: 400, id: nil)
132
- 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)
133
205
  payload = JSON_RPC::Response.new(id: id, error: error_obj).to_json
134
206
 
135
207
  [ status, { "content-type" => CONTENT_TYPE }, [ payload ] ]
136
208
  end
137
209
 
210
+ def invalid_request_response(raw_payload)
211
+ jsonrpc_error_response(:invalid_request, id: extract_id_from_raw_payload(raw_payload))
212
+ end
213
+
138
214
  # Extract ID from raw payload if possible
139
215
  def extract_id_from_raw_payload(raw)
140
216
  return nil unless raw.is_a?(Hash)
141
- raw["id"]
142
- rescue
143
- nil
217
+
218
+ id = raw["id"]
219
+ id if valid_id?(id)
144
220
  end
145
221
  end
146
222
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module JSONRPC_Rails
4
- VERSION = "0.5.4"
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.4
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: []