Source code for galaxy.web.framework.base

"""
A simple WSGI application/framework.
"""
import io
import json
import logging
import os
import socket
import tarfile
import tempfile
import time
import types
from http.cookies import (
    CookieError,
    SimpleCookie,
)
from importlib import import_module
from urllib.parse import urljoin

import routes
import webob.compat
import webob.cookies
import webob.exc
import webob.exc as httpexceptions

# We will use some very basic HTTP/wsgi utilities from the paste library
from paste.response import HeaderDict

from galaxy.util import smart_str
from galaxy.util.resources import resource_string

log = logging.getLogger(__name__)

#: time of the most recent server startup
server_starttime = int(time.time())
try:
    meta_json = json.loads(resource_string(__package__, "meta.json"))
    server_starttime = meta_json.get("epoch") or server_starttime
except Exception:
    meta_json = {}


def __resource_with_deleted(self, member_name, collection_name, **kwargs):
    """
    Method to monkeypatch on to routes.mapper.Mapper which does the same thing
    as resource() with the addition of standardized routes for handling
    elements in Galaxy's "deleted but not really deleted" fashion.
    """
    collection_path = f"{kwargs.get('path_prefix', '')}/{collection_name}/deleted"
    member_path = f"{collection_path}/{{id}}"
    self.connect(
        f"deleted_{collection_name}",
        collection_path,
        controller=collection_name,
        action="index",
        deleted=True,
        conditions=dict(method=["GET"]),
    )
    self.connect(
        f"deleted_{member_name}",
        member_path,
        controller=collection_name,
        action="show",
        deleted=True,
        conditions=dict(method=["GET"]),
    )
    self.connect(
        f"undelete_deleted_{member_name}",
        f"{member_path}/undelete",
        controller=collection_name,
        action="undelete",
        conditions=dict(method=["POST"]),
    )
    self.resource(member_name, collection_name, **kwargs)


routes.Mapper.resource_with_deleted = __resource_with_deleted


[docs]class WebApplication: """ A simple web application which maps requests to objects using routes, and to methods on those objects in the CherryPy style. Thus simple argument mapping in the CherryPy style occurs automatically, but more complicated encoding of arguments in the PATH_INFO can be performed with routes. """
[docs] def __init__(self): """ Create a new web application object. To actually connect some controllers use `add_controller` and `add_route`. Call `finalize_config` when all controllers and routes have been added and `__call__` to handle a request (WSGI style). """ self.controllers = dict() self.api_controllers = dict() self.mapper = routes.Mapper() self.clientside_routes = routes.Mapper(controller_scan=None, register=False) # FIXME: The following two options are deprecated and should be # removed. Consult the Routes documentation. self.mapper.minimization = True self.transaction_factory = DefaultWebTransaction # Set if trace logging is enabled self.trace_logger = None
[docs] def add_ui_controller(self, controller_name, controller): """ Add a controller class to this application. A controller class has methods which handle web requests. To connect a URL to a controller's method use `add_route`. """ log.debug("Enabling '%s' controller, class: %s", controller_name, controller.__class__.__name__) self.controllers[controller_name] = controller
[docs] def add_api_controller(self, controller_name, controller): log.debug("Enabling '%s' API controller, class: %s", controller_name, controller.__class__.__name__) self.api_controllers[controller_name] = controller
[docs] def add_route(self, route, **kwargs): """ Add a route to match a URL with a method. Accepts all keyword arguments of `routes.Mapper.connect`. Every route should result in at least a controller value which corresponds to one of the objects added with `add_controller`. It optionally may yield an `action` argument which will be used to locate the method to call on the controller. Additional arguments will be passed to the method as keyword args. """ self.mapper.connect(route, **kwargs)
[docs] def add_client_route(self, route, controller="root"): self.clientside_routes.connect(route, controller=controller, action="client")
[docs] def set_transaction_factory(self, transaction_factory): """ Use the callable `transaction_factory` to create the transaction which will be passed to requests. """ self.transaction_factory = transaction_factory
[docs] def finalize_config(self): """ Call when application is completely configured and ready to serve requests """ # Create/compile the regular expressions for route mapping self.mapper.create_regs(list(self.controllers.keys())) self.clientside_routes.create_regs()
[docs] def trace(self, **fields): if self.trace_logger: self.trace_logger.log("WebApplication", **fields)
def __call__(self, environ, start_response): """ Call interface as specified by WSGI. Wraps the environment in user friendly objects, finds the appropriate method to handle the request and calls it. """ # Get request_id (set by RequestIDMiddleware): # Used for logging + ensuring request-scoped SQLAlchemy sessions. request_id = environ["request_id"] if self.trace_logger: self.trace_logger.context_set("request_id", request_id) self.trace(message="Starting request") path_info = environ.get("PATH_INFO", "") try: self._model.set_request_id(request_id) # Start SQLAlchemy session scope return self.handle_request(request_id, path_info, environ, start_response) finally: self._model.unset_request_id(request_id) # End SQLAlchemy session scope self.trace(message="Handle request finished") if self.trace_logger: self.trace_logger.context_remove("request_id") def _resolve_map_match(self, map_match, path_info, controllers, use_default=True): # Get the controller class controller_name = map_match.pop("controller", None) controller = controllers.get(controller_name, None) if controller is None: raise webob.exc.HTTPNotFound(f"No controller for {path_info}") # Resolve action method on controller # This is the easiest way to make the controller/action accessible for # url_for invocations. Specifically, grids. action = map_match.pop("action", "index") method = getattr(controller, action, None) if method is None and not use_default: # Skip default, we do this, for example, when we want to fail # through to another mapper. raise webob.exc.HTTPNotFound(f"No action for {path_info}") if method is None: # no matching method, we try for a default method = getattr(controller, "default", None) if method is None: raise webob.exc.HTTPNotFound(f"No action for {path_info}") # Is the method exposed if not getattr(method, "exposed", False): raise webob.exc.HTTPNotFound(f"Action not exposed for {path_info}") # Is the method callable if not callable(method): raise webob.exc.HTTPNotFound(f"Action not callable for {path_info}") return (controller_name, controller, action, method)
[docs] def handle_request(self, request_id, path_info, environ, start_response, body_renderer=None): # Map url using routes client_match = self.clientside_routes.match(path_info, environ) map_match = self.mapper.match(path_info, environ) or client_match if path_info.startswith("/api"): environ["is_api_request"] = True controllers = self.api_controllers else: environ["is_api_request"] = False controllers = self.controllers if map_match is None: raise webob.exc.HTTPNotFound(f"No route for {path_info}") self.trace(path_info=path_info, map_match=map_match) # Setup routes rc = routes.request_config() rc.mapper = self.mapper rc.mapper_dict = map_match server_port = environ["SERVER_PORT"] if isinstance(server_port, int): # Workaround bug in the routes package, which would concatenate this # without casting to str in # https://github.com/bbangert/routes/blob/c4d5a5fb693ce8dc7cf5dbc591861acfc49d5c23/routes/__init__.py#L73 environ["SERVER_PORT"] = str(server_port) rc.environ = environ # Setup the transaction trans = self.transaction_factory(environ) trans.request_id = request_id rc.redirect = trans.response.send_redirect # Resolve mapping to controller/method try: # We don't use default methods if there's a clientside match for this route. use_default = client_match is None controller_name, controller, action, method = self._resolve_map_match( map_match, path_info, controllers, use_default=use_default ) except webob.exc.HTTPNotFound: # Failed, let's check client routes if not environ["is_api_request"] and client_match is not None: controller_name, controller, action, method = self._resolve_map_match( client_match, path_info, controllers ) else: raise trans.controller = controller_name trans.action = action environ[ "controller_action_key" ] = f"{'api' if environ['is_api_request'] else 'web'}.{controller_name}.{action or 'default'}" # Combine mapper args and query string / form args and call kwargs = trans.request.params.mixed() kwargs.update(map_match) # Special key for AJAX debugging, remove to avoid confusing methods kwargs.pop("_", None) try: body = method(trans, **kwargs) except Exception as e: body = self.handle_controller_exception(e, trans, method, **kwargs) if not body: raise body_renderer = body_renderer or self._render_body return body_renderer(trans, body, environ, start_response)
def _render_body(self, trans, body, environ, start_response): # Now figure out what we got back and try to get it to the browser in # a smart way if callable(body): # Assume the callable is another WSGI application to run return body(environ, start_response) elif isinstance(body, tarfile.ExFileObject): # Stream the tarfile member back to the browser body = iterate_file(body) start_response(trans.response.wsgi_status(), trans.response.wsgi_headeritems()) return body elif isinstance(body, io.IOBase): # Stream the file back to the browser return send_file(start_response, trans, body) else: start_response(trans.response.wsgi_status(), trans.response.wsgi_headeritems()) return self.make_body_iterable(trans, body)
[docs] def make_body_iterable(self, trans, body): if isinstance(body, (types.GeneratorType, list, tuple)): # Recursively stream the iterable return flatten(body) elif body is None: # Returns an empty body return [] else: # Worst case scenario return [smart_str(body)]
[docs] def handle_controller_exception(self, e, trans, method, **kwargs): """ Allow handling of exceptions raised in controller methods. """ return False
class WSGIEnvironmentProperty: """ Descriptor that delegates a property to a key in the environ member of the associated object (provides property style access to keys in the WSGI environment) """ def __init__(self, key, default=""): self.key = key self.default = default def __get__(self, obj, type=None): if obj is None: return self return obj.environ.get(self.key, self.default) class LazyProperty: """ Property that replaces itself with a calculated value the first time it is used. """ def __init__(self, func): self.func = func def __get__(self, obj, type=None): if obj is None: return self value = self.func(obj) setattr(obj, self.func.__name__, value) return value lazy_property = LazyProperty
[docs]class DefaultWebTransaction: """ Wraps the state of a single web transaction (request/response cycle). TODO: Provide hooks to allow application specific state to be included in here. """
[docs] def __init__(self, environ): self.environ = environ self.request = Request(environ) self.response = Response()
[docs] @lazy_property def session(self): """ Get the user's session state. This is laze since we rarely use it and the creation/serialization cost is high. """ if "com.saddi.service.session" in self.environ: return self.environ["com.saddi.service.session"].session elif "beaker.session" in self.environ: return self.environ["beaker.session"] else: return None
def _make_file(self, binary=None): # For request.params, override cgi.FieldStorage.make_file to create persistent # tempfiles. Necessary for externalizing the upload tool. It's a little hacky # but for performance reasons it's way better to use Paste's tempfile than to # create a new one and copy. if self._binary_file or self.length >= 0: return tempfile.NamedTemporaryFile("wb+", delete=False) else: return tempfile.NamedTemporaryFile("w+", encoding=self.encoding, newline="\n", delete=False) def _read_lines(self): # Always make a new file self.file = self.make_file() # Adapt `self.__file = None` to Python name mangling of class-private attributes. # We need to patch the original FieldStorage class attribute, not the cgi_FieldStorage # class. self._FieldStorage__file = None if self.outerboundary: self.read_lines_to_outerboundary() else: self.read_lines_to_eof() webob.compat.cgi_FieldStorage.make_file = _make_file webob.compat.cgi_FieldStorage.read_lines = _read_lines class Request(webob.Request): """ Encapsulates an HTTP request. """ def __init__(self, environ): """ Create a new request wrapping the WSGI environment `environ` """ # self.environ = environ webob.Request.__init__(self, environ, charset="utf-8") # Properties that are computed and cached on first use @lazy_property def remote_host(self): try: return socket.gethostbyname(self.remote_addr) except OSError: return self.remote_addr @lazy_property def remote_hostname(self): try: return socket.gethostbyaddr(self.remote_addr)[0] except OSError: return self.remote_addr @lazy_property def cookies(self): cookies = SimpleCookie() cookie_header = self.environ.get("HTTP_COOKIE") if cookie_header: all_cookies = webob.cookies.parse_cookie(cookie_header) galaxy_cookies = {k.decode(): v.decode() for k, v in all_cookies if k.startswith(b"galaxy")} if galaxy_cookies: try: cookies.load(galaxy_cookies) except CookieError: pass return cookies @lazy_property def base(self): return f"{self.scheme}://{self.host}" @lazy_property def url_path(self): return urljoin(self.base, self.environ.get("SCRIPT_NAME", "")) # @lazy_property # def params( self ): # return parse_formvars( self.environ ) @lazy_property def path(self): return self.environ.get("SCRIPT_NAME", "") + self.environ["PATH_INFO"] @lazy_property def browser_url(self): return self.base + self.path # Descriptors that map properties to the associated environment # scheme = WSGIEnvironmentProperty( 'wsgi.url_scheme' ) # remote_addr = WSGIEnvironmentProperty( 'REMOTE_ADDR' ) remote_port = WSGIEnvironmentProperty("REMOTE_PORT") # method = WSGIEnvironmentProperty( 'REQUEST_METHOD' ) # script_name = WSGIEnvironmentProperty( 'SCRIPT_NAME' ) protocol = WSGIEnvironmentProperty("SERVER_PROTOCOL") # query_string = WSGIEnvironmentProperty( 'QUERY_STRING' ) # path_info = WSGIEnvironmentProperty( 'PATH_INFO' ) class Response: """ Describes an HTTP response. Currently very simple since the actual body of the request is handled separately. """ def __init__(self): """ Create a new Response defaulting to HTML content and "200 OK" status """ self.status = "200 OK" self.headers = HeaderDict({"content-type": "text/html; charset=UTF-8"}) self.cookies = SimpleCookie() def set_content_type(self, type_): """ Sets the Content-Type header """ self.headers["content-type"] = type_ def get_content_type(self): return self.headers.get("content-type", None) def send_redirect(self, url): """ Send an HTTP redirect response to (target `url`) """ if "\n" in url or "\r" in url: raise webob.exc.HTTPInternalServerError("Invalid redirect URL encountered.") raise webob.exc.HTTPFound(location=url, headers=self.wsgi_headeritems()) def wsgi_headeritems(self): """ Return headers in format appropriate for WSGI `start_response` """ result = self.headers.headeritems() # Add cookie to header for crumb in self.cookies.values(): header, value = str(crumb).split(": ", 1) result.append((header, value)) return result def wsgi_status(self): """ Return status line in format appropriate for WSGI `start_response` """ if isinstance(self.status, int): exception = webob.exc.status_map.get(self.status) return "%d %s" % (exception.code, exception.title) else: return self.status # ---- Utilities ------------------------------------------------------------ CHUNK_SIZE = 2**16 def send_file(start_response, trans, body): # If configured use X-Accel-Redirect header for nginx base = trans.app.config.nginx_x_accel_redirect_base apache_xsendfile = trans.app.config.apache_xsendfile if base: trans.response.headers.pop("content-length", None) trans.response.headers["X-Accel-Redirect"] = base + os.path.abspath(body.name) body = [b""] elif apache_xsendfile: trans.response.headers.pop("content-length", None) trans.response.headers["X-Sendfile"] = os.path.abspath(body.name) body = [b""] # Fall back on sending the file in chunks else: trans.response.headers["accept-ranges"] = "bytes" start = None end = None if trans.request.method == "HEAD": trans.response.headers["content-length"] = os.path.getsize(body.name) body = b"" if trans.request.range: start = int(trans.request.range.start) file_size = int(trans.response.headers["content-length"]) end = int(file_size if end is None else trans.request.range.end) trans.response.headers["content-length"] = str(end - start) trans.response.headers["content-range"] = f"bytes {start}-{end - 1}/{file_size}" trans.response.status = 206 if body: body = iterate_file(body, start, end) start_response(trans.response.wsgi_status(), trans.response.wsgi_headeritems()) return body def iterate_file(fh, start=None, stop=None): """ Progressively return chunks from `file`. """ length = None if start: fh.seek(start) if stop: length = stop - start while 1: read_size = CHUNK_SIZE if length: read_size = min(CHUNK_SIZE, length) length -= read_size chunk = fh.read(read_size) if not chunk: break yield chunk if length is not None and length == 0: break def flatten(seq): """ Flatten a possible nested set of iterables """ for x in seq: if isinstance(x, (types.GeneratorType, list, tuple)): for y in flatten(x): yield smart_str(y) else: yield smart_str(x)
[docs]def walk_controller_modules(package_name): package = import_module(package_name) controller_dir = package.__path__[0] for fname in os.listdir(controller_dir): if not (fname.startswith("_")) and fname.endswith(".py"): name = fname[:-3] module_name = f"{package_name}.{name}" module = import_module(module_name) yield name, module
__all__ = ( "DefaultWebTransaction", "httpexceptions", "lazy_property", "routes", "server_starttime", "walk_controller_modules", "WebApplication", )