Source code for dossier.web.builder

'''A builder for constructing DossierStack web applications.

.. This software is released under an MIT/X11 open source license.
   Copyright 2012-2015 Diffeo, Inc.
'''
from __future__ import absolute_import, division, print_function

import inspect
import json
import logging

import bottle

from dossier.web import search_engines as builtin_engines
from dossier.web.config import Config
from dossier.web.filters import already_labeled
from dossier.web.routes import app as default_app


logger = logging.getLogger(__name__)


[docs]class WebBuilder(object): '''A builder for constructing DossierStack web applications. DossierStack web services have a lot of knobs, so instead of a single function with a giant list of parameters, we get a "builder" that lets one mutably construct data for building a web application. These "knobs" include, but are not limited to: adding routes or other Bottle applications, injecting services into routes, setting a URL prefix and adding filters and search engines. .. automethod:: __init__ .. automethod:: get_app .. automethod:: mount .. automethod:: set_config .. automethod:: add_search_engine .. automethod:: add_filter .. automethod:: add_routes .. automethod:: inject .. automethod:: enable_cors '''
[docs] def __init__(self, add_default_routes=True): '''Introduce a new builder. You can use method chaining to configure your web application options. e.g., .. code-block:: python app = WebBuilder().enable_cors().get_app() app.run() This code will create a new Bottle web application that enables CORS (Cross Origin Resource Sharing). If ``add_default_routes`` is ``False``, then the default set of routes in ``dossier.web.routes`` is not added. This is only useful if you want to compose multiple Bottle applications constructed through multiple instances of ``WebBuilder``. ''' self.app = bottle.Bottle() self.search_engines = { 'random': builtin_engines.random, 'plain_index_scan': builtin_engines.plain_index_scan, } self.filters = { 'already_labeled': already_labeled, } self.mount_prefix = None self.config = None if add_default_routes: self.add_routes(default_app) # DEPRECATED. Remove. ---AG self.visid_to_dbid, self.dbid_to_visid = lambda x: x, lambda x: x
[docs] def get_app(self): '''Eliminate the builder by producing a new Bottle application. This should be the final call in your method chain. It uses all of the built up options to create a new Bottle application. :rtype: :class:`bottle.Bottle` ''' if self.config is None: # If the user never sets a config instance, then just create # a default. self.config = Config() if self.mount_prefix is None: self.mount_prefix = self.config.config.get('url_prefix') self.inject('config', lambda: self.config) self.inject('kvlclient', lambda: self.config.kvlclient) self.inject('store', lambda: self.config.store) self.inject('label_store', lambda: self.config.label_store) self.inject('search_engines', lambda: self.search_engines) self.inject('filters', lambda: self.filters) self.inject('request', lambda: bottle.request) self.inject('response', lambda: bottle.response) # DEPRECATED. Remove. ---AG self.inject('visid_to_dbid', lambda: self.visid_to_dbid) self.inject('dbid_to_visid', lambda: self.dbid_to_visid) # Also DEPRECATED. self.inject('label_hooks', lambda: []) # Load routes defined in entry points. for extroute in self.config.config.get('external_routes', []): mod, fun_name = extroute.split(':') logger.info('Loading external route: %s', extroute) fun = getattr(__import__(mod, fromlist=[fun_name]), fun_name) self.add_routes(fun()) # This adds the `json=True` feature on routes, which always coerces # the output to JSON. Bottle, by default, only permits dictionaries # to be JSON, which is the correct behavior. (Because returning JSON # arrays is a hazard.) # # So we should fix the routes and then remove this. ---AG self.app.install(JsonPlugin()) # Throw away the app and return it. Because this is elimination! app = self.app self.app = None if self.mount_prefix is not None: root = bottle.Bottle() root.mount(self.mount_prefix, app) return root else: return app
[docs] def mount(self, prefix): '''Mount the application on to the given URL prefix. :param str prefix: A URL prefixi :rtype: :class:`WebBuilder` ''' self.mount_prefix = prefix return self
[docs] def set_config(self, config_instance): '''Set the config instance. By default, this is an instance of :class:`dossier.web.Config`, which provides services like ``kvlclient`` and ``label_store``. Custom services should probably subclass :class:`dossier.web.Config`, but it's not strictly necessary so long as it provides the same set of services (which are used for dependency injection into Bottle routes). :param config_instance: A config instance. :type config_instance: :class:`dossier.web.Config` :rtype: :class:`WebBuilder` ''' self.config = config_instance return self
[docs] def add_search_engine(self, name, engine): '''Adds a search engine with the given name. ``engine`` must be the **class** object rather than an instance. The class *must* be a subclass of :class:`dossier.web.SearchEngine`, which should provide a means of obtaining recommendations given a query. The ``engine`` must be a class so that its dependencies can be injected when the corresponding route is executed by the user. If ``engine`` is ``None``, then it removes a possibly existing search engine named ``name``. :param str name: The name of the search engine. This appears in the list of search engines provided to the user, and is how the search engine is invoked via REST. :param engine: A search engine *class*. :type engine: `type` :rtype: :class:`WebBuilder` ''' if engine is None: self.search_engines.pop(name, None) self.search_engines[name] = engine return self
[docs] def add_filter(self, name, filter): '''Adds a filter with the given name. ``filter`` must be the **class** object rather than an instance. The class *must* be a subclass of :class:`dossier.web.Filter`, which should provide a means of creating a predicate function. The ``filter`` must be a class so that its dependencies can be injected when the corresponding route is executed by the user. If ``filter`` is ``None``, then it removes a possibly existing filter named ``name``. :param str name: The name of the filter. This is how the search engine is invoked via REST. :param engine: A filter *class*. :type engine: `type` :rtype: :class:`WebBuilder` ''' if name is None: self.filters.pop(name, None) self.filters[name] = filter return self
[docs] def add_routes(self, routes): '''Merges a Bottle application into this one. :param routes: A Bottle application or a sequence of routes. :type routes: :class:`bottle.Bottle` or `[bottle route]`. :rtype: :class:`WebBuilder` ''' # Basically the same as `self.app.merge(routes)`, except this # changes the owner of the route so that plugins on `self.app` # apply to the routes given here. if isinstance(routes, bottle.Bottle): routes = routes.routes for route in routes: route.app = self.app self.app.add_route(route) return self
[docs] def inject(self, name, closure): '''Injects ``closure()`` into ``name`` parameters in routes. This sets up dependency injection for parameters named ``name``. When a route is invoked that has a parameter ``name``, then ``closure()`` is passed as that parameter's value. (The closure indirection is so the caller can control the time of construction for objects. For example, you may want to check the health of a database connection.) :param str name: Parameter name. :param function closure: A function with no parameters. :rtype: :class:`WebBuilder` ''' self.app.install(create_injector(name, closure)) return self
[docs] def enable_cors(self): '''Enables Cross Origin Resource Sharing. This makes sure the necessary headers are set so that this web application's routes can be accessed from other origins. :rtype: :class:`WebBuilder` ''' def access_control_headers(): bottle.response.headers['Access-Control-Allow-Origin'] = '*' bottle.response.headers['Access-Control-Allow-Methods'] = \ 'GET, POST, PUT, DELETE, OPTIONS' bottle.response.headers['Access-Control-Allow-Headers'] = \ 'Origin, X-Requested-With, Content-Type, Accept, Authorization' def options_response(res): if bottle.request.method == 'OPTIONS': new_res = bottle.HTTPResponse() new_res.headers['Access-Control-Allow-Origin'] = '*' new_res.headers['Access-Control-Allow-Methods'] = \ bottle.request.headers.get( 'Access-Control-Request-Method', '') new_res.headers['Access-Control-Allow-Headers'] = \ bottle.request.headers.get( 'Access-Control-Request-Headers', '') return new_res res.headers['Allow'] += ', OPTIONS' return bottle.request.app.default_error_handler(res) self.app.add_hook('after_request', access_control_headers) self.app.error_handler[int(405)] = options_response return self
def set_visid_to_dbid(self, f): 'DEPRECATED. DO NOT USE.' self.visid_to_dbid = f return self def set_dbid_to_visid(self, f): 'DEPRECATED. DO NOT USE.' self.dbid_to_visid = f return self
def create_injector(param_name, fun_param_value): '''Dependency injection with Bottle. This creates a simple dependency injector that will map ``param_name`` in routes to the value ``fun_param_value()`` each time the route is invoked. ``fun_param_value`` is a closure so that it is lazily evaluated. This is useful for handling thread local services like database connections. :param str param_name: name of function parameter to inject into :param fun_param_value: the value to insert :type fun_param_value: a closure that can be applied with zero arguments ''' class _(object): api = 2 def apply(self, callback, route): if param_name not in inspect.getargspec(route.callback)[0]: return callback def _(*args, **kwargs): pval = fun_param_value() if pval is None: logger.error('service "%s" unavailable', param_name) bottle.abort(503, 'service "%s" unavailable' % param_name) return kwargs[param_name] = pval return callback(*args, **kwargs) return _ return _() class JsonPlugin(object): '''A custom JSON plugin for Bottle. Bottle has this functionality by default, but it is only triggered when the return value of a route is a ``dict``. This permits the programmer to write `json=True` into the route decorator, which causes the response to *always* be JSON. Basically, it just wraps the return value in ``json.dumps`` and sets the HTTP content type header appropriately. ''' api = 2 name = 'json_response' def apply(self, callback, route): if not route.config.get('json', False): return callback def _(*args, **kwargs): bottle.response.content_type = 'application/json' return json.dumps(callback(*args, **kwargs), indent=2) return _ def add_cli_arguments(p): p.add_argument('--bottle-debug', action='store_true', help='Enable Bottle\'s debug mode.') p.add_argument('--reload', action='store_true', help='Enable Bottle\'s reloading functionality.') p.add_argument('--port', type=int, default=8080) p.add_argument('--host', default='localhost') p.add_argument('--server', default='wsgiref', help='The web server to use. You only need to change this ' 'if you\'re running a production server.')