PageRenderTime 60ms CodeModel.GetById 19ms RepoModel.GetById 1ms app.codeStats 0ms

/indra/lib/python/indra/ipc/siesta.py

https://bitbucket.org/lindenlab/viewer-beta/
Python | 468 lines | 459 code | 0 blank | 9 comment | 0 complexity | 764b8e495ff801c59da1a725dbea1d49 MD5 | raw file
Possible License(s): LGPL-2.1
  1. """\
  2. @file siesta.py
  3. @brief A tiny llsd based RESTful web services framework
  4. $LicenseInfo:firstyear=2008&license=mit$
  5. Copyright (c) 2008, Linden Research, Inc.
  6. Permission is hereby granted, free of charge, to any person obtaining a copy
  7. of this software and associated documentation files (the "Software"), to deal
  8. in the Software without restriction, including without limitation the rights
  9. to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
  10. copies of the Software, and to permit persons to whom the Software is
  11. furnished to do so, subject to the following conditions:
  12. The above copyright notice and this permission notice shall be included in
  13. all copies or substantial portions of the Software.
  14. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
  15. IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
  16. FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
  17. AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
  18. LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
  19. OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
  20. THE SOFTWARE.
  21. $/LicenseInfo$
  22. """
  23. from indra.base import config
  24. from indra.base import llsd
  25. from webob import exc
  26. import webob
  27. import re, socket
  28. try:
  29. from cStringIO import StringIO
  30. except ImportError:
  31. from StringIO import StringIO
  32. try:
  33. import cjson
  34. json_decode = cjson.decode
  35. json_encode = cjson.encode
  36. JsonDecodeError = cjson.DecodeError
  37. JsonEncodeError = cjson.EncodeError
  38. except ImportError:
  39. import simplejson
  40. json_decode = simplejson.loads
  41. json_encode = simplejson.dumps
  42. JsonDecodeError = ValueError
  43. JsonEncodeError = TypeError
  44. llsd_parsers = {
  45. 'application/json': json_decode,
  46. llsd.BINARY_MIME_TYPE: llsd.parse_binary,
  47. 'application/llsd+notation': llsd.parse_notation,
  48. llsd.XML_MIME_TYPE: llsd.parse_xml,
  49. 'application/xml': llsd.parse_xml,
  50. }
  51. def mime_type(content_type):
  52. '''Given a Content-Type header, return only the MIME type.'''
  53. return content_type.split(';', 1)[0].strip().lower()
  54. class BodyLLSD(object):
  55. '''Give a webob Request or Response an llsd based "content" property.
  56. Getting the content property parses the body, and caches the result.
  57. Setting the content property formats a payload, and the body property
  58. is set.'''
  59. def _llsd__get(self):
  60. '''Get, set, or delete the LLSD value stored in this object.'''
  61. try:
  62. return self._llsd
  63. except AttributeError:
  64. if not self.body:
  65. raise AttributeError('No llsd attribute has been set')
  66. else:
  67. mtype = mime_type(self.content_type)
  68. try:
  69. parser = llsd_parsers[mtype]
  70. except KeyError:
  71. raise exc.HTTPUnsupportedMediaType(
  72. 'Content type %s not supported' % mtype).exception
  73. try:
  74. self._llsd = parser(self.body)
  75. except (llsd.LLSDParseError, JsonDecodeError, TypeError), err:
  76. raise exc.HTTPBadRequest(
  77. 'Could not parse body: %r' % err.args).exception
  78. return self._llsd
  79. def _llsd__set(self, val):
  80. req = getattr(self, 'request', None)
  81. if req is not None:
  82. formatter, ctype = formatter_for_request(req)
  83. self.content_type = ctype
  84. else:
  85. formatter, ctype = formatter_for_mime_type(
  86. mime_type(self.content_type))
  87. self.body = formatter(val)
  88. def _llsd__del(self):
  89. if hasattr(self, '_llsd'):
  90. del self._llsd
  91. content = property(_llsd__get, _llsd__set, _llsd__del)
  92. class Response(webob.Response, BodyLLSD):
  93. '''Response class with LLSD support.
  94. A sensible default content type is used.
  95. Setting the llsd property also sets the body. Getting the llsd
  96. property parses the body if necessary.
  97. If you set the body property directly, the llsd property will be
  98. deleted.'''
  99. default_content_type = 'application/llsd+xml'
  100. def _body__set(self, body):
  101. if hasattr(self, '_llsd'):
  102. del self._llsd
  103. super(Response, self)._body__set(body)
  104. def cache_forever(self):
  105. self.cache_expires(86400 * 365)
  106. body = property(webob.Response._body__get, _body__set,
  107. webob.Response._body__del,
  108. webob.Response._body__get.__doc__)
  109. class Request(webob.Request, BodyLLSD):
  110. '''Request class with LLSD support.
  111. Sensible content type and accept headers are used by default.
  112. Setting the content property also sets the body. Getting the content
  113. property parses the body if necessary.
  114. If you set the body property directly, the content property will be
  115. deleted.'''
  116. default_content_type = 'application/llsd+xml'
  117. default_accept = ('application/llsd+xml; q=0.5, '
  118. 'application/llsd+notation; q=0.3, '
  119. 'application/llsd+binary; q=0.2, '
  120. 'application/xml; q=0.1, '
  121. 'application/json; q=0.0')
  122. def __init__(self, environ=None, *args, **kwargs):
  123. if environ is None:
  124. environ = {}
  125. else:
  126. environ = environ.copy()
  127. if 'CONTENT_TYPE' not in environ:
  128. environ['CONTENT_TYPE'] = self.default_content_type
  129. if 'HTTP_ACCEPT' not in environ:
  130. environ['HTTP_ACCEPT'] = self.default_accept
  131. super(Request, self).__init__(environ, *args, **kwargs)
  132. def _body__set(self, body):
  133. if hasattr(self, '_llsd'):
  134. del self._llsd
  135. super(Request, self)._body__set(body)
  136. def path_urljoin(self, *parts):
  137. return '/'.join([path_url.rstrip('/')] + list(parts))
  138. body = property(webob.Request._body__get, _body__set,
  139. webob.Request._body__del, webob.Request._body__get.__doc__)
  140. def create_response(self, content=None, status='200 OK',
  141. conditional_response=webob.NoDefault):
  142. resp = self.ResponseClass(status=status, request=self,
  143. conditional_response=conditional_response)
  144. resp.content = content
  145. return resp
  146. def curl(self):
  147. '''Create and fill out a pycurl easy object from this request.'''
  148. import pycurl
  149. c = pycurl.Curl()
  150. c.setopt(pycurl.URL, self.url())
  151. if self.headers:
  152. c.setopt(pycurl.HTTPHEADER,
  153. ['%s: %s' % (k, self.headers[k]) for k in self.headers])
  154. c.setopt(pycurl.FOLLOWLOCATION, True)
  155. c.setopt(pycurl.AUTOREFERER, True)
  156. c.setopt(pycurl.MAXREDIRS, 16)
  157. c.setopt(pycurl.NOSIGNAL, True)
  158. c.setopt(pycurl.READFUNCTION, self.body_file.read)
  159. c.setopt(pycurl.SSL_VERIFYHOST, 2)
  160. if self.method == 'POST':
  161. c.setopt(pycurl.POST, True)
  162. post301 = getattr(pycurl, 'POST301', None)
  163. if post301 is not None:
  164. # Added in libcurl 7.17.1.
  165. c.setopt(post301, True)
  166. elif self.method == 'PUT':
  167. c.setopt(pycurl.PUT, True)
  168. elif self.method != 'GET':
  169. c.setopt(pycurl.CUSTOMREQUEST, self.method)
  170. return c
  171. Request.ResponseClass = Response
  172. Response.RequestClass = Request
  173. llsd_formatters = {
  174. 'application/json': json_encode,
  175. 'application/llsd+binary': llsd.format_binary,
  176. 'application/llsd+notation': llsd.format_notation,
  177. 'application/llsd+xml': llsd.format_xml,
  178. 'application/xml': llsd.format_xml,
  179. }
  180. formatter_qualities = (
  181. ('application/llsd+xml', 1.0),
  182. ('application/llsd+notation', 0.5),
  183. ('application/llsd+binary', 0.4),
  184. ('application/xml', 0.3),
  185. ('application/json', 0.2),
  186. )
  187. def formatter_for_mime_type(mime_type):
  188. '''Return a formatter that encodes to the given MIME type.
  189. The result is a pair of function and MIME type.'''
  190. try:
  191. return llsd_formatters[mime_type], mime_type
  192. except KeyError:
  193. raise exc.HTTPInternalServerError(
  194. 'Could not use MIME type %r to format response' %
  195. mime_type).exception
  196. def formatter_for_request(req):
  197. '''Return a formatter that encodes to the preferred type of the client.
  198. The result is a pair of function and actual MIME type.'''
  199. ctype = req.accept.best_match(formatter_qualities)
  200. try:
  201. return llsd_formatters[ctype], ctype
  202. except KeyError:
  203. raise exc.HTTPNotAcceptable().exception
  204. def wsgi_adapter(func, environ, start_response):
  205. '''Adapt a Siesta callable to act as a WSGI application.'''
  206. # Process the request as appropriate.
  207. try:
  208. req = Request(environ)
  209. #print req.urlvars
  210. resp = func(req, **req.urlvars)
  211. if not isinstance(resp, webob.Response):
  212. try:
  213. formatter, ctype = formatter_for_request(req)
  214. resp = req.ResponseClass(formatter(resp), content_type=ctype)
  215. resp._llsd = resp
  216. except (JsonEncodeError, TypeError), err:
  217. resp = exc.HTTPInternalServerError(
  218. detail='Could not format response')
  219. except exc.HTTPException, e:
  220. resp = e
  221. except socket.error, e:
  222. resp = exc.HTTPInternalServerError(detail=e.args[1])
  223. return resp(environ, start_response)
  224. def llsd_callable(func):
  225. '''Turn a callable into a Siesta application.'''
  226. def replacement(environ, start_response):
  227. return wsgi_adapter(func, environ, start_response)
  228. return replacement
  229. def llsd_method(http_method, func):
  230. def replacement(environ, start_response):
  231. if environ['REQUEST_METHOD'] == http_method:
  232. return wsgi_adapter(func, environ, start_response)
  233. return exc.HTTPMethodNotAllowed()(environ, start_response)
  234. return replacement
  235. http11_methods = 'OPTIONS GET HEAD POST PUT DELETE TRACE CONNECT'.split()
  236. http11_methods.sort()
  237. def llsd_class(cls):
  238. '''Turn a class into a Siesta application.
  239. A new instance is created for each request. A HTTP method FOO is
  240. turned into a call to the handle_foo method of the instance.'''
  241. def foo(req, **kwargs):
  242. instance = cls()
  243. method = req.method.lower()
  244. try:
  245. handler = getattr(instance, 'handle_' + method)
  246. except AttributeError:
  247. allowed = [m for m in http11_methods
  248. if hasattr(instance, 'handle_' + m.lower())]
  249. raise exc.HTTPMethodNotAllowed(
  250. headers={'Allow': ', '.join(allowed)}).exception
  251. #print "kwargs: ", kwargs
  252. return handler(req, **kwargs)
  253. def replacement(environ, start_response):
  254. return wsgi_adapter(foo, environ, start_response)
  255. return replacement
  256. def curl(reqs):
  257. import pycurl
  258. m = pycurl.CurlMulti()
  259. curls = [r.curl() for r in reqs]
  260. io = {}
  261. for c in curls:
  262. fp = StringIO()
  263. hdr = StringIO()
  264. c.setopt(pycurl.WRITEFUNCTION, fp.write)
  265. c.setopt(pycurl.HEADERFUNCTION, hdr.write)
  266. io[id(c)] = fp, hdr
  267. m.handles = curls
  268. try:
  269. while True:
  270. ret, num_handles = m.perform()
  271. if ret != pycurl.E_CALL_MULTI_PERFORM:
  272. break
  273. finally:
  274. m.close()
  275. for req, c in zip(reqs, curls):
  276. fp, hdr = io[id(c)]
  277. hdr.seek(0)
  278. status = hdr.readline().rstrip()
  279. headers = []
  280. name, values = None, None
  281. # XXX We don't currently handle bogus header data.
  282. for line in hdr.readlines():
  283. if not line[0].isspace():
  284. if name:
  285. headers.append((name, ' '.join(values)))
  286. name, value = line.strip().split(':', 1)
  287. value = [value]
  288. else:
  289. values.append(line.strip())
  290. if name:
  291. headers.append((name, ' '.join(values)))
  292. resp = c.ResponseClass(fp.getvalue(), status, headers, request=req)
  293. route_re = re.compile(r'''
  294. \{ # exact character "{"
  295. (\w*) # "config" or variable (restricted to a-z, 0-9, _)
  296. (?:([:~])([^}]+))? # optional :type or ~regex part
  297. \} # exact character "}"
  298. ''', re.VERBOSE)
  299. predefined_regexps = {
  300. 'uuid': r'[a-f0-9][a-f0-9-]{31,35}',
  301. 'int': r'\d+',
  302. 'host': r'[a-z0-9][a-z0-9\-\.]*',
  303. }
  304. def compile_route(route):
  305. fp = StringIO()
  306. last_pos = 0
  307. for match in route_re.finditer(route):
  308. #print "matches: ", match.groups()
  309. fp.write(re.escape(route[last_pos:match.start()]))
  310. var_name = match.group(1)
  311. sep = match.group(2)
  312. expr = match.group(3)
  313. if var_name == 'config':
  314. expr = re.escape(str(config.get(var_name)))
  315. else:
  316. if expr:
  317. if sep == ':':
  318. expr = predefined_regexps[expr]
  319. # otherwise, treat what follows '~' as a regexp
  320. else:
  321. expr = '[^/]+'
  322. if var_name != '':
  323. expr = '(?P<%s>%s)' % (var_name, expr)
  324. else:
  325. expr = '(%s)' % (expr,)
  326. fp.write(expr)
  327. last_pos = match.end()
  328. fp.write(re.escape(route[last_pos:]))
  329. compiled_route = '^%s$' % fp.getvalue()
  330. #print route, "->", compiled_route
  331. return compiled_route
  332. class Router(object):
  333. '''WSGI routing class. Parses a URL and hands off a request to
  334. some other WSGI application. If no suitable application is found,
  335. responds with a 404.'''
  336. def __init__(self):
  337. self._new_routes = []
  338. self._routes = []
  339. self._paths = []
  340. def add(self, route, app, methods=None):
  341. self._new_routes.append((route, app, methods))
  342. def _create_routes(self):
  343. for route, app, methods in self._new_routes:
  344. self._paths.append(route)
  345. self._routes.append(
  346. (re.compile(compile_route(route)),
  347. app,
  348. methods and dict.fromkeys(methods)))
  349. self._new_routes = []
  350. def __call__(self, environ, start_response):
  351. # load up the config from the config file. Only needs to be
  352. # done once per interpreter. This is the entry point of all
  353. # siesta applications, so this is where we trap it.
  354. _conf = config.get_config()
  355. if _conf is None:
  356. import os.path
  357. fname = os.path.join(
  358. environ.get('ll.config_dir', '/local/linden/etc'),
  359. 'indra.xml')
  360. config.load(fname)
  361. # proceed with handling the request
  362. self._create_routes()
  363. path_info = environ['PATH_INFO']
  364. request_method = environ['REQUEST_METHOD']
  365. allowed = []
  366. for regex, app, methods in self._routes:
  367. m = regex.match(path_info)
  368. if m:
  369. #print "groupdict:",m.groupdict()
  370. if not methods or request_method in methods:
  371. environ['paste.urlvars'] = m.groupdict()
  372. return app(environ, start_response)
  373. else:
  374. allowed += methods
  375. if allowed:
  376. allowed = dict.fromkeys(allows).keys()
  377. allowed.sort()
  378. resp = exc.HTTPMethodNotAllowed(
  379. headers={'Allow': ', '.join(allowed)})
  380. else:
  381. resp = exc.HTTPNotFound()
  382. return resp(environ, start_response)