##// END OF EJS Templates
api: make jsonrpc registry ordered so doc generation can be ordered
dan -
r617:0bbd3a1b default
parent child Browse files
Show More
@@ -1,503 +1,505 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2011-2016 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 import inspect
22 22 import itertools
23 23 import logging
24 24 import types
25 25
26 26 import decorator
27 27 import venusian
28 from collections import OrderedDict
29
28 30 from pyramid.exceptions import ConfigurationError
29 31 from pyramid.renderers import render
30 32 from pyramid.response import Response
31 33 from pyramid.httpexceptions import HTTPNotFound
32 34
33 35 from rhodecode.api.exc import (
34 36 JSONRPCBaseError, JSONRPCError, JSONRPCForbidden, JSONRPCValidationError)
35 37 from rhodecode.lib.auth import AuthUser
36 38 from rhodecode.lib.base import get_ip_addr
37 39 from rhodecode.lib.ext_json import json
38 40 from rhodecode.lib.utils2 import safe_str
39 41 from rhodecode.lib.plugins.utils import get_plugin_settings
40 42 from rhodecode.model.db import User, UserApiKeys
41 43
42 44 log = logging.getLogger(__name__)
43 45
44 46 DEFAULT_RENDERER = 'jsonrpc_renderer'
45 47 DEFAULT_URL = '/_admin/apiv2'
46 48
47 49
48 50 class ExtJsonRenderer(object):
49 51 """
50 52 Custom renderer that mkaes use of our ext_json lib
51 53
52 54 """
53 55
54 56 def __init__(self, serializer=json.dumps, **kw):
55 57 """ Any keyword arguments will be passed to the ``serializer``
56 58 function."""
57 59 self.serializer = serializer
58 60 self.kw = kw
59 61
60 62 def __call__(self, info):
61 63 """ Returns a plain JSON-encoded string with content-type
62 64 ``application/json``. The content-type may be overridden by
63 65 setting ``request.response.content_type``."""
64 66
65 67 def _render(value, system):
66 68 request = system.get('request')
67 69 if request is not None:
68 70 response = request.response
69 71 ct = response.content_type
70 72 if ct == response.default_content_type:
71 73 response.content_type = 'application/json'
72 74
73 75 return self.serializer(value, **self.kw)
74 76
75 77 return _render
76 78
77 79
78 80 def jsonrpc_response(request, result):
79 81 rpc_id = getattr(request, 'rpc_id', None)
80 82 response = request.response
81 83
82 84 # store content_type before render is called
83 85 ct = response.content_type
84 86
85 87 ret_value = ''
86 88 if rpc_id:
87 89 ret_value = {
88 90 'id': rpc_id,
89 91 'result': result,
90 92 'error': None,
91 93 }
92 94
93 95 # fetch deprecation warnings, and store it inside results
94 96 deprecation = getattr(request, 'rpc_deprecation', None)
95 97 if deprecation:
96 98 ret_value['DEPRECATION_WARNING'] = deprecation
97 99
98 100 raw_body = render(DEFAULT_RENDERER, ret_value, request=request)
99 101 response.body = safe_str(raw_body, response.charset)
100 102
101 103 if ct == response.default_content_type:
102 104 response.content_type = 'application/json'
103 105
104 106 return response
105 107
106 108
107 109 def jsonrpc_error(request, message, retid=None, code=None):
108 110 """
109 111 Generate a Response object with a JSON-RPC error body
110 112
111 113 :param code:
112 114 :param retid:
113 115 :param message:
114 116 """
115 117 err_dict = {'id': retid, 'result': None, 'error': message}
116 118 body = render(DEFAULT_RENDERER, err_dict, request=request).encode('utf-8')
117 119 return Response(
118 120 body=body,
119 121 status=code,
120 122 content_type='application/json'
121 123 )
122 124
123 125
124 126 def exception_view(exc, request):
125 127 rpc_id = getattr(request, 'rpc_id', None)
126 128
127 129 fault_message = 'undefined error'
128 130 if isinstance(exc, JSONRPCError):
129 131 fault_message = exc.message
130 132 log.debug('json-rpc error rpc_id:%s "%s"', rpc_id, fault_message)
131 133 elif isinstance(exc, JSONRPCValidationError):
132 134 colander_exc = exc.colander_exception
133 135 #TODO: think maybe of nicer way to serialize errors ?
134 136 fault_message = colander_exc.asdict()
135 137 log.debug('json-rpc error rpc_id:%s "%s"', rpc_id, fault_message)
136 138 elif isinstance(exc, JSONRPCForbidden):
137 139 fault_message = 'Access was denied to this resource.'
138 140 log.warning('json-rpc forbidden call rpc_id:%s "%s"', rpc_id, fault_message)
139 141 elif isinstance(exc, HTTPNotFound):
140 142 method = request.rpc_method
141 143 log.debug('json-rpc method `%s` not found in list of '
142 144 'api calls: %s, rpc_id:%s',
143 145 method, request.registry.jsonrpc_methods.keys(), rpc_id)
144 146 fault_message = "No such method: {}".format(method)
145 147
146 148 return jsonrpc_error(request, fault_message, rpc_id)
147 149
148 150
149 151 def request_view(request):
150 152 """
151 153 Main request handling method. It handles all logic to call a specific
152 154 exposed method
153 155 """
154 156
155 157 # check if we can find this session using api_key, get_by_auth_token
156 158 # search not expired tokens only
157 159
158 160 try:
159 161 u = User.get_by_auth_token(request.rpc_api_key)
160 162
161 163 if u is None:
162 164 return jsonrpc_error(
163 165 request, retid=request.rpc_id, message='Invalid API KEY')
164 166
165 167 if not u.active:
166 168 return jsonrpc_error(
167 169 request, retid=request.rpc_id,
168 170 message='Request from this user not allowed')
169 171
170 172 # check if we are allowed to use this IP
171 173 auth_u = AuthUser(
172 174 u.user_id, request.rpc_api_key, ip_addr=request.rpc_ip_addr)
173 175 if not auth_u.ip_allowed:
174 176 return jsonrpc_error(
175 177 request, retid=request.rpc_id,
176 178 message='Request from IP:%s not allowed' % (
177 179 request.rpc_ip_addr,))
178 180 else:
179 181 log.info('Access for IP:%s allowed' % (request.rpc_ip_addr,))
180 182
181 183 # now check if token is valid for API
182 184 role = UserApiKeys.ROLE_API
183 185 extra_auth_tokens = [
184 186 x.api_key for x in User.extra_valid_auth_tokens(u, role=role)]
185 187 active_tokens = [u.api_key] + extra_auth_tokens
186 188
187 189 log.debug('Checking if API key has proper role')
188 190 if request.rpc_api_key not in active_tokens:
189 191 return jsonrpc_error(
190 192 request, retid=request.rpc_id,
191 193 message='API KEY has bad role for an API call')
192 194
193 195 except Exception as e:
194 196 log.exception('Error on API AUTH')
195 197 return jsonrpc_error(
196 198 request, retid=request.rpc_id, message='Invalid API KEY')
197 199
198 200 method = request.rpc_method
199 201 func = request.registry.jsonrpc_methods[method]
200 202
201 203 # now that we have a method, add request._req_params to
202 204 # self.kargs and dispatch control to WGIController
203 205 argspec = inspect.getargspec(func)
204 206 arglist = argspec[0]
205 207 defaults = map(type, argspec[3] or [])
206 208 default_empty = types.NotImplementedType
207 209
208 210 # kw arguments required by this method
209 211 func_kwargs = dict(itertools.izip_longest(
210 212 reversed(arglist), reversed(defaults), fillvalue=default_empty))
211 213
212 214 # This attribute will need to be first param of a method that uses
213 215 # api_key, which is translated to instance of user at that name
214 216 user_var = 'apiuser'
215 217 request_var = 'request'
216 218
217 219 for arg in [user_var, request_var]:
218 220 if arg not in arglist:
219 221 return jsonrpc_error(
220 222 request,
221 223 retid=request.rpc_id,
222 224 message='This method [%s] does not support '
223 225 'required parameter `%s`' % (func.__name__, arg))
224 226
225 227 # get our arglist and check if we provided them as args
226 228 for arg, default in func_kwargs.items():
227 229 if arg in [user_var, request_var]:
228 230 # user_var and request_var are pre-hardcoded parameters and we
229 231 # don't need to do any translation
230 232 continue
231 233
232 234 # skip the required param check if it's default value is
233 235 # NotImplementedType (default_empty)
234 236 if default == default_empty and arg not in request.rpc_params:
235 237 return jsonrpc_error(
236 238 request,
237 239 retid=request.rpc_id,
238 240 message=('Missing non optional `%s` arg in JSON DATA' % arg)
239 241 )
240 242
241 243 # sanitze extra passed arguments
242 244 for k in request.rpc_params.keys()[:]:
243 245 if k not in func_kwargs:
244 246 del request.rpc_params[k]
245 247
246 248 call_params = request.rpc_params
247 249 call_params.update({
248 250 'request': request,
249 251 'apiuser': auth_u
250 252 })
251 253 try:
252 254 ret_value = func(**call_params)
253 255 return jsonrpc_response(request, ret_value)
254 256 except JSONRPCBaseError:
255 257 raise
256 258 except Exception:
257 259 log.exception('Unhandled exception occured on api call: %s', func)
258 260 return jsonrpc_error(request, retid=request.rpc_id,
259 261 message='Internal server error')
260 262
261 263
262 264 def setup_request(request):
263 265 """
264 266 Parse a JSON-RPC request body. It's used inside the predicates method
265 267 to validate and bootstrap requests for usage in rpc calls.
266 268
267 269 We need to raise JSONRPCError here if we want to return some errors back to
268 270 user.
269 271 """
270 272 log.debug('Executing setup request: %r', request)
271 273 request.rpc_ip_addr = get_ip_addr(request.environ)
272 274 # TODO: marcink, deprecate GET at some point
273 275 if request.method not in ['POST', 'GET']:
274 276 log.debug('unsupported request method "%s"', request.method)
275 277 raise JSONRPCError(
276 278 'unsupported request method "%s". Please use POST' % request.method)
277 279
278 280 if 'CONTENT_LENGTH' not in request.environ:
279 281 log.debug("No Content-Length")
280 282 raise JSONRPCError("Empty body, No Content-Length in request")
281 283
282 284 else:
283 285 length = request.environ['CONTENT_LENGTH']
284 286 log.debug('Content-Length: %s', length)
285 287
286 288 if length == 0:
287 289 log.debug("Content-Length is 0")
288 290 raise JSONRPCError("Content-Length is 0")
289 291
290 292 raw_body = request.body
291 293 try:
292 294 json_body = json.loads(raw_body)
293 295 except ValueError as e:
294 296 # catch JSON errors Here
295 297 raise JSONRPCError("JSON parse error ERR:%s RAW:%r" % (e, raw_body))
296 298
297 299 request.rpc_id = json_body.get('id')
298 300 request.rpc_method = json_body.get('method')
299 301
300 302 # check required base parameters
301 303 try:
302 304 api_key = json_body.get('api_key')
303 305 if not api_key:
304 306 api_key = json_body.get('auth_token')
305 307
306 308 if not api_key:
307 309 raise KeyError('api_key or auth_token')
308 310
309 311 request.rpc_api_key = api_key
310 312 request.rpc_id = json_body['id']
311 313 request.rpc_method = json_body['method']
312 314 request.rpc_params = json_body['args'] \
313 315 if isinstance(json_body['args'], dict) else {}
314 316
315 317 log.debug(
316 318 'method: %s, params: %s' % (request.rpc_method, request.rpc_params))
317 319 except KeyError as e:
318 320 raise JSONRPCError('Incorrect JSON data. Missing %s' % e)
319 321
320 322 log.debug('setup complete, now handling method:%s rpcid:%s',
321 323 request.rpc_method, request.rpc_id, )
322 324
323 325
324 326 class RoutePredicate(object):
325 327 def __init__(self, val, config):
326 328 self.val = val
327 329
328 330 def text(self):
329 331 return 'jsonrpc route = %s' % self.val
330 332
331 333 phash = text
332 334
333 335 def __call__(self, info, request):
334 336 if self.val:
335 337 # potentially setup and bootstrap our call
336 338 setup_request(request)
337 339
338 340 # Always return True so that even if it isn't a valid RPC it
339 341 # will fall through to the underlaying handlers like notfound_view
340 342 return True
341 343
342 344
343 345 class NotFoundPredicate(object):
344 346 def __init__(self, val, config):
345 347 self.val = val
346 348
347 349 def text(self):
348 350 return 'jsonrpc method not found = %s' % self.val
349 351
350 352 phash = text
351 353
352 354 def __call__(self, info, request):
353 355 return hasattr(request, 'rpc_method')
354 356
355 357
356 358 class MethodPredicate(object):
357 359 def __init__(self, val, config):
358 360 self.method = val
359 361
360 362 def text(self):
361 363 return 'jsonrpc method = %s' % self.method
362 364
363 365 phash = text
364 366
365 367 def __call__(self, context, request):
366 368 # we need to explicitly return False here, so pyramid doesn't try to
367 369 # execute our view directly. We need our main handler to execute things
368 370 return getattr(request, 'rpc_method') == self.method
369 371
370 372
371 373 def add_jsonrpc_method(config, view, **kwargs):
372 374 # pop the method name
373 375 method = kwargs.pop('method', None)
374 376
375 377 if method is None:
376 378 raise ConfigurationError(
377 379 'Cannot register a JSON-RPC method without specifying the '
378 380 '"method"')
379 381
380 382 # we define custom predicate, to enable to detect conflicting methods,
381 383 # those predicates are kind of "translation" from the decorator variables
382 384 # to internal predicates names
383 385
384 386 kwargs['jsonrpc_method'] = method
385 387
386 388 # register our view into global view store for validation
387 389 config.registry.jsonrpc_methods[method] = view
388 390
389 391 # we're using our main request_view handler, here, so each method
390 392 # has a unified handler for itself
391 393 config.add_view(request_view, route_name='apiv2', **kwargs)
392 394
393 395
394 396 class jsonrpc_method(object):
395 397 """
396 398 decorator that works similar to @add_view_config decorator,
397 399 but tailored for our JSON RPC
398 400 """
399 401
400 402 venusian = venusian # for testing injection
401 403
402 404 def __init__(self, method=None, **kwargs):
403 405 self.method = method
404 406 self.kwargs = kwargs
405 407
406 408 def __call__(self, wrapped):
407 409 kwargs = self.kwargs.copy()
408 410 kwargs['method'] = self.method or wrapped.__name__
409 411 depth = kwargs.pop('_depth', 0)
410 412
411 413 def callback(context, name, ob):
412 414 config = context.config.with_package(info.module)
413 415 config.add_jsonrpc_method(view=ob, **kwargs)
414 416
415 417 info = venusian.attach(wrapped, callback, category='pyramid',
416 418 depth=depth + 1)
417 419 if info.scope == 'class':
418 420 # ensure that attr is set if decorating a class method
419 421 kwargs.setdefault('attr', wrapped.__name__)
420 422
421 423 kwargs['_info'] = info.codeinfo # fbo action_method
422 424 return wrapped
423 425
424 426
425 427 class jsonrpc_deprecated_method(object):
426 428 """
427 429 Marks method as deprecated, adds log.warning, and inject special key to
428 430 the request variable to mark method as deprecated.
429 431 Also injects special docstring that extract_docs will catch to mark
430 432 method as deprecated.
431 433
432 434 :param use_method: specify which method should be used instead of
433 435 the decorated one
434 436
435 437 Use like::
436 438
437 439 @jsonrpc_method()
438 440 @jsonrpc_deprecated_method(use_method='new_func', deprecated_at_version='3.0.0')
439 441 def old_func(request, apiuser, arg1, arg2):
440 442 ...
441 443 """
442 444
443 445 def __init__(self, use_method, deprecated_at_version):
444 446 self.use_method = use_method
445 447 self.deprecated_at_version = deprecated_at_version
446 448 self.deprecated_msg = ''
447 449
448 450 def __call__(self, func):
449 451 self.deprecated_msg = 'Please use method `{method}` instead.'.format(
450 452 method=self.use_method)
451 453
452 454 docstring = """\n
453 455 .. deprecated:: {version}
454 456
455 457 {deprecation_message}
456 458
457 459 {original_docstring}
458 460 """
459 461 func.__doc__ = docstring.format(
460 462 version=self.deprecated_at_version,
461 463 deprecation_message=self.deprecated_msg,
462 464 original_docstring=func.__doc__)
463 465 return decorator.decorator(self.__wrapper, func)
464 466
465 467 def __wrapper(self, func, *fargs, **fkwargs):
466 468 log.warning('DEPRECATED API CALL on function %s, please '
467 469 'use `%s` instead', func, self.use_method)
468 470 # alter function docstring to mark as deprecated, this is picked up
469 471 # via fabric file that generates API DOC.
470 472 result = func(*fargs, **fkwargs)
471 473
472 474 request = fargs[0]
473 475 request.rpc_deprecation = 'DEPRECATED METHOD ' + self.deprecated_msg
474 476 return result
475 477
476 478
477 479 def includeme(config):
478 480 plugin_module = 'rhodecode.api'
479 481 plugin_settings = get_plugin_settings(
480 482 plugin_module, config.registry.settings)
481 483
482 484 if not hasattr(config.registry, 'jsonrpc_methods'):
483 config.registry.jsonrpc_methods = {}
485 config.registry.jsonrpc_methods = OrderedDict()
484 486
485 487 # match filter by given method only
486 488 config.add_view_predicate(
487 489 'jsonrpc_method', MethodPredicate)
488 490
489 491 config.add_renderer(DEFAULT_RENDERER, ExtJsonRenderer(
490 492 serializer=json.dumps, indent=4))
491 493 config.add_directive('add_jsonrpc_method', add_jsonrpc_method)
492 494
493 495 config.add_route_predicate(
494 496 'jsonrpc_call', RoutePredicate)
495 497
496 498 config.add_route(
497 499 'apiv2', plugin_settings.get('url', DEFAULT_URL), jsonrpc_call=True)
498 500
499 501 config.scan(plugin_module, ignore='rhodecode.api.tests')
500 502 # register some exception handling view
501 503 config.add_view(exception_view, context=JSONRPCBaseError)
502 504 config.add_view_predicate('jsonrpc_method_not_found', NotFoundPredicate)
503 505 config.add_notfound_view(exception_view, jsonrpc_method_not_found=True)
General Comments 0
You need to be logged in to leave comments. Login now