jsonrpc2 0.0.9 → 0.2.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.
@@ -1,3 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rack'
4
+ require 'digest'
1
5
  require 'jsonrpc2'
2
6
  require 'jsonrpc2/accept'
3
7
  require 'jsonrpc2/textile'
@@ -8,6 +12,16 @@ require 'json'
8
12
  require 'base64'
9
13
 
10
14
  module JSONRPC2
15
+ module_function
16
+
17
+ def environment
18
+ ENV['RACK_ENV'] || ENV['RAILS_ENV'] || 'development'
19
+ end
20
+
21
+ def development?
22
+ environment.eql?('development')
23
+ end
24
+
11
25
  # Authentication failed error - only used if transport level authentication isn't.
12
26
  # e.g. BasicAuth returns a 401 HTTP response, rather than throw this.
13
27
  class AuthFail < RuntimeError; end
@@ -15,301 +29,381 @@ module JSONRPC2
15
29
  # API error - thrown when an error is detected, e.g. params of wrong type.
16
30
  class APIFail < RuntimeError; end
17
31
 
18
- # Base class for JSONRPC2 interface
19
- class Interface
20
- class << self
32
+ # KnownError - thrown when a predictable error occurs.
33
+ class KnownError < RuntimeError
34
+ attr_accessor :code, :data
35
+ def self.exception(args)
36
+ code, message, data = *args
37
+ exception = new(message)
38
+ exception.code = code
39
+ exception.data = data
40
+ exception
41
+ end
42
+ end
21
43
 
22
- # @!group Authentication
44
+ # Base class for JSONRPC2 interface
45
+ class Interface
46
+ class << self
47
+ # @!group Authentication
48
+ # Get/set authenticator object for API interface - see {JSONRPC2::Auth} and {JSONRPC2::BasicAuth}
49
+ #
50
+ # @param [#check] args An object that responds to check(environment, json_call_data)
51
+ # @return [#check, nil] Currently set object or nil
52
+ def auth_with *args
53
+ if args.empty?
54
+ return @auth_with
55
+ else
56
+ @auth_with = args[0]
57
+ end
58
+ end
59
+ # @!endgroup
60
+
61
+ # @!group Rack-related
62
+ # Rack compatible call handler
63
+ #
64
+ # @param [Hash] environment Rack environment hash
65
+ # @return [Array<Fixnum, Hash<String,String>, Array<String>>] Rack-compatible response
66
+ def call(environment)
67
+ environment['json.request-id'] = Digest::MD5.hexdigest("#{$host ||= Socket.gethostname}-#{$$}-#{Time.now.to_f}")[0,8]
68
+ request = Rack::Request.new(environment)
69
+ catch :rack_response do
70
+ best = JSONRPC2::HTTPUtils.which(environment['HTTP_ACCEPT'], %w[text/html application/json-rpc application/json])
71
+
72
+ if request.path_info =~ %r'/_assets' or request.path_info == '/favicon.ico'
73
+ best = 'text/html' # hack for assets
74
+ end
75
+
76
+ case best
77
+ when 'text/html', 'text/css', 'image/png' # Assume browser
78
+ monitor_time(environment, request.POST['__json__']) { JSONRPC2::HTML.call(self, request) }
79
+ when 'application/json-rpc', 'application/json', nil # Assume correct by default
80
+ environment['rack.input'].rewind
81
+ raw = environment['rack.input'].read
82
+ data = JSON.parse(raw) if raw.to_s.size >= 2
83
+ monitor_time(environment, raw) { self.new(environment).rack_dispatch(data) }
84
+ else
85
+ [406, {'Content-Type' => 'text/html'},
86
+ ["<!DOCTYPE html><html><head><title>Media type mismatch</title></head><body>I am unable to acquiesce to your request</body></html>"]]
87
+ end
88
+ end
23
89
 
24
- # Get/set authenticator object for API interface - see {JSONRPC2::Auth} and {JSONRPC2::BasicAuth}
25
- #
26
- # @param [#check] args An object that responds to check(environment, json_call_data)
27
- # @return [#check, nil] Currently set object or nil
28
- def auth_with *args
29
- if args.empty?
30
- return @auth_with
31
- else
32
- @auth_with = args[0]
33
- end
90
+ rescue Exception => e
91
+ if environment['rack.logger'].respond_to?(:error)
92
+ environment['rack.logger'].error "#{e.class}: #{e.message} - #{e.backtrace * "\n "}"
93
+ end
94
+ raise e.class, e.message, e.backtrace
95
+ end
96
+
97
+ private
98
+
99
+ def monitor_time(env, data, &block)
100
+ if env['rack.logger'].respond_to?(:info)
101
+ if env["HTTP_AUTHORIZATION"].to_s =~ /Basic /i
102
+ auth = Base64.decode64(env["HTTP_AUTHORIZATION"].to_s.sub(/Basic /i, '')) rescue nil
103
+ auth ||= env["HTTP_AUTHORIZATION"]
104
+ else
105
+ auth = env["HTTP_AUTHORIZATION"]
106
+ end
107
+ env['rack.logger'].info("[JSON-RPC2] #{env['json.request-id']} #{env['REQUEST_URI']} - Auth: #{auth}, Data: #{data.is_a?(String) ? data : data.inspect}")
108
+ end
109
+ t = Time.now.to_f
110
+ return yield
111
+ ensure
112
+ if env['rack.logger'].respond_to?(:info)
113
+ env['rack.logger'].info("[JSON-RPC2] #{env['json.request-id']} Completed in #{'%.3f' % ((Time.now.to_f - t) * 1000)}ms#{ $! ? " - exception = #{$!.class}:#{$!.message}" : "" }")
114
+ end
115
+ end
116
+ # @!endgroup
34
117
  end
35
118
 
36
- # @!endgroup
119
+ # Create new interface object
120
+ #
121
+ # @param [Hash] env Rack environment
122
+ def initialize(env)
123
+ @_jsonrpc_env = env
124
+ @_jsonrpc_request = Rack::Request.new(env)
125
+ end
37
126
 
38
- # @!group Rack-related
127
+ # Internal
128
+ def rack_dispatch(rpcData)
129
+ catch(:rack_response) do
130
+ json = dispatch(rpcData)
131
+ [200, {'Content-Type' => 'application/json-rpc'}, [json]]
132
+ end
133
+ end
39
134
 
40
- # Rack compatible call handler
135
+ # Dispatch call to api method(s)
41
136
  #
42
- # @param [Hash] environment Rack environment hash
43
- # @return [Array<Fixnum, Hash<String,String>, Array<String>>] Rack-compatible response
44
- def call(environment)
45
- request = Rack::Request.new(environment)
46
- catch :rack_response do
47
- best = JSONRPC2::HTTPUtils.which(environment['HTTP_ACCEPT'], %w[text/html application/json-rpc application/json])
48
-
49
- if request.path_info =~ %r'/_assets'
50
- best = 'text/html' # hack for assets
51
- end
52
-
53
- case best
54
- when 'text/html', 'text/css', 'image/png' # Assume browser
55
- JSONRPC2::HTML.call(self, request)
56
- when 'application/json-rpc', 'application/json', nil # Assume correct by default
57
- environment['rack.input'].rewind
58
- data = JSON.parse(environment['rack.input'].read)
59
- self.new(environment).rack_dispatch(data)
60
- else
61
- [406, {'Content-Type' => 'text/html'},
62
- ["<!DOCTYPE html><html><head><title>Media type mismatch</title></head><body>I am unable to acquiesce to your request</body></html>"]]
63
- end
137
+ # @param [Hash,Array] rpc_data Array of calls or Hash containing one call
138
+ # @return [Hash,Array] Depends on input, but either a hash result or an array of results corresponding to calls.
139
+ def dispatch(rpc_data)
140
+ result = case rpc_data
141
+ when Array
142
+ rpc_data.map { |rpc| dispatch_single(rpc) }
143
+ else
144
+ dispatch_single(rpc_data)
64
145
  end
146
+
147
+ return result.to_json
65
148
  end
66
149
 
67
- # @!endgroup
150
+ protected
68
151
 
69
- end
152
+ # JSON result helper
153
+ def response_ok(id, result)
154
+ { 'jsonrpc' => '2.0', 'result' => result, 'id' => id }
155
+ end
70
156
 
71
- # Create new interface object
72
- #
73
- # @param [Hash] env Rack environment
74
- def initialize(env)
75
- @_jsonrpc_env = env
76
- @_jsonrpc_request = Rack::Request.new(env)
77
- end
78
- # Internal
79
- def rack_dispatch(rpcData)
80
- catch(:rack_response) do
81
- json = dispatch(rpcData)
82
- [200, {'Content-Type' => 'application/json-rpc'}, [json]]
157
+ # JSON error helper
158
+ def response_error(code, message, data)
159
+ { 'jsonrpc' => '2.0', 'error' => { 'code' => code, 'message' => message, 'data' => data }, 'id' => (@_jsonrpc_call && @_jsonrpc_call['id'] || nil) }
83
160
  end
84
- end
85
161
 
86
- # Dispatch call to api method(s)
87
- #
88
- # @param [Hash,Array] rpc_data Array of calls or Hash containing one call
89
- # @return [Hash,Array] Depends on input, but either a hash result or an array of results corresponding to calls.
90
- def dispatch(rpc_data)
91
- case rpc_data
92
- when Array
93
- rpc_data.map { |rpc| dispatch_single(rpc) }.to_json
94
- else
95
- dispatch_single(rpc_data).to_json
162
+ # Params helper
163
+ def params
164
+ @_jsonrpc_call['params']
96
165
  end
97
- end
98
166
 
99
- protected
100
- # JSON result helper
101
- def response_ok(id, result)
102
- { 'jsonrpc' => '2.0', 'result' => result, 'id' => id }
103
- end
104
- # JSON error helper
105
- def response_error(code, message, data)
106
- { 'jsonrpc' => '2.0', 'error' => { 'code' => code, 'message' => message, 'data' => data }, 'id' => (@_jsonrpc_call && @_jsonrpc_call['id'] || nil) }
107
- end
108
- # Params helper
109
- def params
110
- @_jsonrpc_call['params']
111
- end
112
- # Auth info
113
- def auth
114
- @_jsonrpc_auth
115
- end
116
- # Rack::Request
117
- def request
118
- @_jsonrpc_request
119
- end
120
- # Check call validity and authentication & make a single method call
121
- #
122
- # @param [Hash] rpc JSON-RPC-2 call
123
- def dispatch_single(rpc)
124
- unless rpc.has_key?('id') && rpc.has_key?('method') && rpc['jsonrpc'].eql?('2.0')
125
- return response_error(-32600, 'Invalid request', nil)
167
+ # Auth info
168
+ def auth
169
+ @_jsonrpc_auth
126
170
  end
127
- @_jsonrpc_call = rpc
128
171
 
129
- begin
130
- if self.class.auth_with && ! @_jsonrpc_auth
131
- (@_jsonrpc_auth = self.class.auth_with.client_check(@_jsonrpc_env, rpc)) or raise AuthFail, "Invalid credentials"
132
- end
172
+ # Rack::Request
173
+ def request
174
+ @_jsonrpc_request
175
+ end
133
176
 
134
- call(rpc['method'], rpc['id'], rpc['params'])
135
- rescue AuthFail => e
136
- response_error(-32000, "AuthFail: #{e.class}: #{e.message}", {}) # XXX: Change me
137
- rescue APIFail => e
138
- response_error(-32000, "APIFail: #{e.class}: #{e.message}", {}) # XXX: Change me
139
- rescue Exception => e
140
- response_error(-32000, "#{e.class}: #{e.message}", e.backtrace) # XXX: Change me
177
+ def env
178
+ @_jsonrpc_env
141
179
  end
142
- end
143
- # List API methods
144
- #
145
- # @return [Array] List of api method names
146
- def api_methods
147
- public_methods(false).map(&:to_s) - ['rack_dispatch', 'dispatch']
148
- end
149
180
 
150
- # Call method, checking param and return types
151
- #
152
- # @param [String] method Method name
153
- # @param [Integer] id Method call ID - for response
154
- # @param [Hash] params Method parameters
155
- # @return [Hash] JSON response
156
- def call(method, id, params)
157
- if api_methods.include?(method)
158
- begin
159
- Types.valid_params?(self.class, method, params)
160
- rescue Exception => e
161
- return response_error(-32602, "Invalid params - #{e.message}", {})
181
+ # Logger
182
+ def logger
183
+ @_jsonrpc_logger ||= (@_jsonrpc_env['rack.logger'] || Rack::NullLogger.new("null"))
184
+ end
185
+
186
+ # Check call validity and authentication & make a single method call
187
+ #
188
+ # @param [Hash] rpc JSON-RPC-2 call
189
+ def dispatch_single(rpc)
190
+ t = Time.now.to_f
191
+
192
+ result = _dispatch_single(rpc)
193
+
194
+ if result['result']
195
+ logger.info("[JSON-RPC2] #{env['json.request-id']} Call completed OK in #{'%.3f' % ((Time.now.to_f - t) * 1000)}ms")
196
+ elsif result['error']
197
+ logger.info("[JSON-RPC2] #{env['json.request-id']} Call to ##{rpc['method']} failed in #{'%.3f' % ((Time.now.to_f - t) * 1000)}ms with error #{result['error']['code']} - #{result['error']['message']}")
162
198
  end
163
199
 
164
- if self.method(method).arity.zero?
165
- result = send(method)
166
- else
167
- result = send(method, params)
200
+ result
201
+ end
202
+
203
+ def _dispatch_single(rpc)
204
+ unless rpc.has_key?('id') && rpc.has_key?('method') && rpc['jsonrpc'].eql?('2.0')
205
+ return response_error(-32600, 'Invalid request', nil)
168
206
  end
207
+ @_jsonrpc_call = rpc
169
208
 
170
209
  begin
171
- Types.valid_result?(self.class, method, result)
210
+ if self.class.auth_with && ! @_jsonrpc_auth
211
+ (@_jsonrpc_auth = self.class.auth_with.client_check(@_jsonrpc_env, rpc)) or raise AuthFail, "Invalid credentials"
212
+ end
213
+
214
+ call(rpc['method'], rpc['id'], rpc['params'])
215
+ rescue AuthFail => e
216
+ response_error(-32000, 'AuthFail: Invalid credentials', {})
217
+ rescue APIFail => e
218
+ response_error(-32000, 'APIFail', {})
219
+ rescue KnownError => e
220
+ response_error(e.code, 'An error occurred', {})
172
221
  rescue Exception => e
173
- return response_error(-32602, "Invalid result - #{e.message}", {})
222
+ log_error("Internal error calling #{rpc.inspect} - #{e.class}: #{e.message} #{e.backtrace.join("\n ")}")
223
+ invoke_server_error_hook(e)
224
+ response_error(-32000, "An error occurred. Check logs for details", {})
174
225
  end
175
-
176
- response_ok(id, result)
177
- else
178
- response_error(-32601, "Unknown method `#{method.inspect}'", {})
179
226
  end
180
- end
181
227
 
182
- class << self
183
- # Store parameter in internal hash when building API
184
- def ___append_param name, type, options
185
- @params ||= []
186
- unless options.has_key?(:required)
187
- options[:required] = true
188
- end
189
- @params << options.merge({ :name => name, :type => type })
228
+ # List API methods
229
+ #
230
+ # @return [Array] List of api method names
231
+ def api_methods
232
+ public_methods(false).map(&:to_s) - ['rack_dispatch', 'dispatch']
190
233
  end
191
- private :___append_param
192
234
 
193
- # @!group DSL
194
-
195
- # Define a named parameter of type #type for next method
235
+ # Call method, checking param and return types
196
236
  #
197
- # @param [String] name parameter name
198
- # @param [String] type description of type see {Types}
199
- def param name, type, desc = nil, options = nil
200
- if options.nil? && desc.is_a?(Hash)
201
- options, desc = desc, nil
237
+ # @param [String] method Method name
238
+ # @param [Integer] id Method call ID - for response
239
+ # @param [Hash] params Method parameters
240
+ # @return [Hash] JSON response
241
+ def call(method, id, params)
242
+ if api_methods.include?(method)
243
+ begin
244
+ Types.valid_params?(self.class, method, params)
245
+ rescue Types::InvalidParamsError => e
246
+ return response_error(-32602, "Invalid params - #{e.message}", {})
247
+ end
248
+
249
+ if self.method(method).arity.zero?
250
+ result = send(method)
251
+ else
252
+ result = send(method, params)
253
+ end
254
+
255
+ Types.valid_result?(self.class, method, result)
256
+
257
+ response_ok(id, result)
258
+ else
259
+ response_error(-32601, "Unknown method `#{method.inspect}'", {})
202
260
  end
203
- options ||= {}
204
- options[:desc] = desc if desc.is_a?(String)
205
-
206
- ___append_param name, type, options
207
261
  end
208
262
 
209
- # Define an optional parameter for next method
210
- def optional name, type, desc = nil, options = nil
211
- if options.nil? && desc.is_a?(Hash)
212
- options, desc = desc, nil
263
+ class << self
264
+ # Store parameter in internal hash when building API
265
+ def ___append_param name, type, options
266
+ @params ||= []
267
+ unless options.has_key?(:required)
268
+ options[:required] = true
269
+ end
270
+ @params << options.merge({ :name => name, :type => type })
213
271
  end
214
- options ||= {}
215
- options[:desc] = desc if desc.is_a?(String)
272
+ private :___append_param
273
+
274
+ # @!group DSL
275
+ # Define a named parameter of type #type for next method
276
+ #
277
+ # @param [String] name parameter name
278
+ # @param [String] type description of type see {Types}
279
+ def param name, type, desc = nil, options = nil
280
+ if options.nil? && desc.is_a?(Hash)
281
+ options, desc = desc, nil
282
+ end
283
+ options ||= {}
284
+ options[:desc] = desc if desc.is_a?(String)
216
285
 
217
- ___append_param(name, type, options.merge(:required => false))
218
- end
286
+ ___append_param name, type, options
287
+ end
219
288
 
220
- # Define type of return value for next method
221
- def result type, desc = nil
222
- @result = { :type => type, :desc => desc }
223
- end
289
+ # Define an optional parameter for next method
290
+ def optional name, type, desc = nil, options = nil
291
+ if options.nil? && desc.is_a?(Hash)
292
+ options, desc = desc, nil
293
+ end
294
+ options ||= {}
295
+ options[:desc] = desc if desc.is_a?(String)
224
296
 
225
- # Set description for next method
226
- def desc str
227
- @desc = str
228
- end
297
+ ___append_param(name, type, options.merge(:required => false))
298
+ end
229
299
 
230
- # Add an example for next method
231
- def example desc, code
232
- @examples ||= []
233
- @examples << { :desc => desc, :code => code }
234
- end
300
+ # Define type of return value for next method
301
+ def result type, desc = nil
302
+ @result = { :type => type, :desc => desc }
303
+ end
235
304
 
236
- # Define a custom type
237
- def type name, *fields
238
- @types ||= {}
239
- type = JsonObjectType.new(name, fields)
305
+ # Set description for next method
306
+ def desc str
307
+ @desc = str
308
+ end
240
309
 
241
- if block_given?
242
- yield(type)
243
- end
310
+ # Add an example for next method
311
+ def example desc, code
312
+ @examples ||= []
313
+ @examples << { :desc => desc, :code => code }
314
+ end
244
315
 
245
- @types[name] = type
246
- end
316
+ # Define a custom type
317
+ def type name, *fields
318
+ @types ||= {}
319
+ type = JsonObjectType.new(name, fields)
247
320
 
248
- # Group methods
249
- def section name, summary=nil
250
- @sections ||= []
251
- @sections << {:name => name, :summary => summary}
321
+ if block_given?
322
+ yield(type)
323
+ end
252
324
 
253
- @current_section = name
254
- if block_given?
255
- yield
256
- @current_section = nil
257
- end
258
- end
325
+ @types[name] = type
326
+ end
259
327
 
260
- # Exclude next method from documentation
261
- def nodoc
262
- @nodoc = true
263
- end
328
+ # Group methods
329
+ def section name, summary=nil
330
+ @sections ||= []
331
+ @sections << {:name => name, :summary => summary}
264
332
 
265
- # Set interface title
266
- def title str = nil
267
- @title = str if str
268
- end
333
+ @current_section = name
334
+ if block_given?
335
+ yield
336
+ @current_section = nil
337
+ end
338
+ end
269
339
 
270
- # Sets introduction for interface
271
- def introduction str = nil
272
- @introduction = str if str
273
- end
340
+ # Exclude next method from documentation
341
+ def nodoc
342
+ @nodoc = true
343
+ end
274
344
 
275
- # @!endgroup
276
-
277
- # Catch methods added to class & store documentation
278
- def method_added(name)
279
- return if self == JSONRPC2::Interface
280
- @about ||= {}
281
- method = {}
282
- method[:params] = @params if @params
283
- method[:returns] = @result if @result
284
- method[:desc] = @desc if @desc
285
- method[:examples] = @examples if @examples
286
-
287
- if method.empty?
288
- if public_methods(false).include?(name)
289
- unless @nodoc
290
- #logger.info("#{name} has no API documentation... :(")
345
+ # Set interface title
346
+ def title str = nil
347
+ @title = str if str
348
+ end
349
+
350
+ # Sets introduction for interface
351
+ def introduction str = nil
352
+ @introduction = str if str
353
+ end
354
+ # @!endgroup
355
+
356
+ # Catch methods added to class & store documentation
357
+ def method_added(name)
358
+ return if self == JSONRPC2::Interface
359
+ @about ||= {}
360
+ method = {}
361
+ method[:params] = @params if @params
362
+ method[:returns] = @result if @result
363
+ method[:desc] = @desc if @desc
364
+ method[:examples] = @examples if @examples
365
+
366
+ if method.empty?
367
+ if public_methods(false).include?(name)
368
+ unless @nodoc
369
+ #logger.info("#{name} has no API documentation... :(")
370
+ end
371
+ else
372
+ #logger.debug("#{name} isn't public - so no API")
291
373
  end
292
374
  else
293
- #logger.debug("#{name} isn't public - so no API")
375
+ method[:name] = name
376
+ method[:section] = @current_section
377
+ method[:index] = @about.size
378
+ @about[name.to_s] = method
294
379
  end
295
- else
296
- method[:name] = name
297
- method[:section] = @current_section
298
- method[:index] = @about.size
299
- @about[name.to_s] = method
380
+
381
+ @result = nil
382
+ @params = nil
383
+ @desc = nil
384
+ @examples = nil
385
+ @nodoc = false
300
386
  end
387
+ private :method_added
388
+ attr_reader :about, :types
389
+ end
390
+
391
+ extend JSONRPC2::TextileEmitter
301
392
 
302
- @result = nil
303
- @params = nil
304
- @desc = nil
305
- @examples = nil
306
- @nodoc = false
393
+ private
394
+
395
+ def invoke_server_error_hook(error)
396
+ on_server_error(request_id: env['json.request-id'], error: error)
397
+ rescue => error
398
+ log_error("Server error hook failed - #{error.class}: #{error.message} #{error.backtrace.join("\n ")}")
307
399
  end
308
- private :method_added
309
- attr_reader :about, :types
310
400
 
311
- end
401
+ # Available for reimplementation by a subclass, noop by default
402
+ def on_server_error(request_id:, error:)
403
+ end
312
404
 
313
- extend JSONRPC2::TextileEmitter
314
- end
405
+ def log_error(message)
406
+ logger.error("#{env['json.request-id']} #{message}") if logger.respond_to?(:error)
407
+ end
408
+ end
315
409
  end