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 +4 -4
- data/README.md +26 -2
- data/lib/jsonrpc_rails/middleware/validator.rb +121 -45
- data/lib/jsonrpc_rails/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e098dc20d9c1c05b35e30788afb28d8671fb93be44b4526be851fb9d5800fa80
|
|
4
|
+
data.tar.gz: efa72ded518abf7bec6a360bad11beacde8564d426fb07829c32d1a0e390a7f3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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**
|
|
104
|
-
3. **Stores** the validated
|
|
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
|
|
19
|
-
ENV_PAYLOAD_KEY
|
|
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
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
43
|
-
return jsonrpc_error_response(:invalid_request, id: id)
|
|
70
|
+
return jsonrpc_error_response(:invalid_request)
|
|
44
71
|
end
|
|
45
72
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
93
|
-
(obj.keys - allowed).empty?
|
|
134
|
+
no_conflicting_members?(obj, %w[jsonrpc method params id])
|
|
94
135
|
end
|
|
95
136
|
|
|
96
|
-
def
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
101
|
-
|
|
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
|
|
106
|
-
return
|
|
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
|
-
|
|
109
|
-
|
|
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,
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
217
|
+
|
|
218
|
+
id = raw["id"]
|
|
219
|
+
id if valid_id?(id)
|
|
144
220
|
end
|
|
145
221
|
end
|
|
146
222
|
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.
|
|
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:
|
|
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: []
|