Source code for stencila.host_http_server

import json
import logging
import os
import six
from six.moves import socketserver
from io import BytesIO, StringIO
import random
import re
import string
import threading
import traceback
import mimetypes

from werkzeug.wrappers import Request, Response
from werkzeug.serving import BaseWSGIServer


[docs]class HostHttpServer(object): """ A HTTP server for a Host Provides access to a ``Host`` via a REST-like HTTP protocol using ``POST`` to create new instance and ``PUT`` to run one of it's methods. Implements authorization using single-, or multi-, use "tickets" and session tokens. The following example illustrates creating a ``PythonContext`` and then running it's ``execute`` method. It uses the ``http`` command line tool (https://httpie.org/) for brevity and session management but you could also use ``curl`` or other HTTP client. .. code-block:: bash # Start the server > python -m stencila Host has started at: http://127.0.0.1:2000/?ticket=w8Z0ZkuWlz8Y Use Ctrl+C to stop # Then in another shell create a new PythonContext (using the above ticket # to obtain access authorization and a session token) using POST > http --session=/tmp/session.json POST :2000/PythonContext?ticket=w8Z0ZkuWlz8Y HTTP/1.0 200 OK Content-Length: 21 Content-Type: application/json Date: Wed, 28 Feb 2018 21:36:37 GMT Server: Werkzeug/0.12.2 Python/2.7.12 Set-Cookie: token=PjvskQ38vtuJQg2hNYEHwPppvw8RKbs0AaYcA9uStannZkGfRr3I0g9jyeQD3L3f; Path=/ "pythonContext1" # Then use the returned name of the PythonContext instance to run it's "execute" method # using PUT > http --session=/tmp/session.json PUT :2000/pythonContext1!execute code='sys.version' HTTP/1.0 200 OK Content-Length: 153 Content-Type: application/json Date: Wed, 28 Feb 2018 21:39:54 GMT Server: Werkzeug/0.12.2 Python/2.7.12 { "messages": [], "value": { "data": "2.7.12 (default, Nov 20 2017, 18:23:56) [GCC 5.4.0 20160609]", "type": "string" } } """ def __init__(self, host, address='127.0.0.1', port=2000): self._host = host self._address = address self._port = port self._server = None @property def url(self): """ Get the URL of the server :returns: A URL string """ return 'http://%s:%s' % (self._address, self._port) if self._server else None
[docs] def start(self, real=True): """ Start the server """ # Setup a logger for Werkzeug (which prevents it from printing to stdout) logger = logging.getLogger('werkzeug') log_path = os.path.join(self._host.temp_dir(), 'logs', 'py-host-http-server.log') if not os.path.exists(os.path.dirname(log_path)): os.makedirs(os.path.dirname(log_path)) handler = logging.FileHandler(log_path) handler.setLevel(logging.WARNING) formatter = logging.Formatter('%(asctime)s|%(levelname)s|%(message)s') handler.setFormatter(formatter) logger.addHandler(handler) # This shouldn't be necessary but if you don't do it, occaisionally # the statup message goes to stdout import werkzeug werkzeug._internal._logger = logger # Find an available port and serve on it if real: while self._port < 65535: try: # Do not use a threaded server because (amongst possible other issues) # a SQLte connection can only be used from within the same thread self._server = BaseWSGIServer( self._address, self._port, self ) except socketserver.socket.error as exc: # pragma: no cover if exc.args[0] == 98: self._port += 10 else: raise else: thread = threading.Thread(target=self._server.serve_forever) thread.daemon = True thread.start() break return self
[docs] def stop(self, real=True): """ Stop the server """ if self._server: self._server.shutdown() self._server = None return self
def __call__(self, environ, start_response): """ The WSGI application interface. """ request = Request(environ) response = self.handle(request) return response(environ, start_response)
[docs] def handle(self, request): """ Handle a HTTP request """ response = Response() try: # Check authorization authorized = False if not self._host.key: authorized = True else: auth_header = request.headers.get('Authorization') if auth_header: match = re.match(r'^Bearer (.+)', auth_header) if match: token = match.group(1) try: self._host.authorize_token(token) except Exception as exc: return self.error403(request, response, str(exc)) else: authorized = True # Add CORS headers used to control access by browsers. In particular, CORS # can prevent access by XHR requests made by Javascript in third party sites. # See https://developer.mozilla.org/en-US/docs/Web/HTTP/Access_control_CORS # Get the Origin header (sent in CORS and POST requests) and fall back to Referer header # if it is not present (either of these should be present in most browser requests) origin = request.headers.get('Origin') if not origin and request.headers.get('Referer'): match = re.match(r'^https?://([\w.]+)(:\d+)?', request.headers.get('Referer')) if match: origin = match.group(0) # Check that host is in whitelist if origin: match = re.match(r'^https?://([\w.]+)(:\d+)?', origin) if match: host = match.group(1) match = re.match(r'^((127\.0\.0\.1)|(localhost)|(([^.]+\.)?stenci\.la))$', host) if not match: origin = None else: origin = None # If an origin has been found and is authorized set CORS headers # Without these headers browser XHR request get an error like: # No 'Access-Control-Allow-Origin' header is present on the requested resource. # Origin 'http://evil.hackers:4000' is therefore not allowed access. if origin: # 'Simple' requests (GET and POST XHR requests) response.headers['Access-Control-Allow-Origin'] = origin # Allow sending cookies and other credentials response.headers['Access-Control-Allow-Credentials'] = 'true' # Pre-flighted requests by OPTIONS method (made before PUT, DELETE etc # XHR requests and in other circumstances) # get additional CORS headers if request.method == 'OPTIONS': # Allowable methods and headers response.headers['Access-Control-Allow-Methods'] = 'GET, POST, PUT, DELETE, OPTIONS' response.headers['Access-Control-Allow-Headers'] = 'Content-Type' # "how long the response to the preflight request can be cached for without # sending another preflight request" response.headers['Access-Control-Max-Age'] = '86400' # 24 hours if request.method == 'OPTIONS': # For preflighted CORS OPTIONS requests return an empty response with headers set # (https://developer.mozilla.org/en-US/docs/Web/HTTP/Access_control_CORS#Preflighted_requests) return response else: # Route request to a method endpoint = self.route(request.method, request.path, authorized) if not endpoint: return self.error400() method_name = endpoint[0] method_args = endpoint[1:] method = getattr(self, method_name) response = method(request, response, *method_args) return response except Exception as exc: return self.error500(request, response)
[docs] def route(self, verb, path, authorized=False): """ Route a HTTP request """ if path == '/': return ('static', 'index.html') if path[:8] == '/static/': return ('static', path[8:]) if path == '/manifest': return ('run', 'manifest') if not authorized: return ('error401', path) if path[:9] == '/environ/': if verb == 'POST': return ('run', 'startup', path[9:]) if verb == 'DELETE': return ('run', 'shutdown', path[9:]) match = re.match(r'^/(.+?)(!(.+))?$', path) if match: id = match.group(1) method = match.group(3) if verb == 'POST' and id: return ('run', 'create', id) if verb == 'GET' and id: return ('run', 'get', id) elif verb == 'PUT' and id and method: return ('run', 'call', id, method) elif verb == 'DELETE' and id: return ('run', 'delete', id) return None
[docs] def static(self, request, response, path): """ Handle a GET request for a static file """ static_path = os.path.abspath(os.path.join(os.path.dirname(__file__), 'static')) requested_path = os.path.abspath(os.path.join(static_path, path)) if os.path.commonprefix([static_path, requested_path]) != static_path: return self.error403(request, response) elif not os.path.exists(requested_path): return self.error404(request, response) else: response.set_data(open(requested_path).read()) response.headers['Content-Type'] = mimetypes.guess_type(path)[0] return response
[docs] def run(self, request, response, method, *args): """ Run a host method """ args = list(args) if request.data: arg = json.loads(request.data.decode()) args.append(arg) result = getattr(self._host, method)(*args) data = to_json(result) response.set_data(data) response.headers['Content-Type'] = 'application/json' return response
[docs] def error(self, request, response, code, name, what = ''): response.status_code = code response.set_data('%s: %s' % (name, what)) response.headers['Content-Type'] = 'text/plain' return response
[docs] def error400(self, request, response, what = ''): return self.error(request, response, 400, 'Bad request', what)
[docs] def error401(self, request, response, what = ''): return self.error(request, response, 401, 'Unauthorized', what)
[docs] def error403(self, request, response, what = ''): return self.error(request, response, 403, 'Forbidden', what)
[docs] def error404(self, request, response, what = ''): return self.error(request, response, 404, 'Not found', what)
[docs] def error500(self, request, response): stream = StringIO() if six.PY3 else BytesIO() traceback.print_exc(file=stream) trace = stream.getvalue() return self.error(request, response, 500, 'Internal error', trace)
class JSONEncoder(json.JSONEncoder): """ Custom JSON encoder for Python object """ def default(self, object): try: iterable = iter(object) except TypeError: pass else: return list(iterable) try: properties = object.__dict__ except AttributeError: pass else: return dict((key, value) for key, value in properties.items() if not key.startswith('_')) return JSONEncoder.default(self, object) def to_json(object): return JSONEncoder().encode(object)