diff --git a/ckan/config/middleware/flask_app.py b/ckan/config/middleware/flask_app.py index 6f731cf38f6..beae58e39cf 100644 --- a/ckan/config/middleware/flask_app.py +++ b/ckan/config/middleware/flask_app.py @@ -3,14 +3,19 @@ import os import importlib import inspect +import itertools from flask import Flask, Blueprint from flask.ctx import _AppCtxGlobals from werkzeug.exceptions import HTTPException +from werkzeug.routing import Rule from ckan.common import config, g import ckan.lib.app_globals as app_globals +from ckan.plugins import PluginImplementations +from ckan.plugins.interfaces import IBlueprint + import logging log = logging.getLogger(__name__) @@ -22,6 +27,7 @@ def make_flask_stack(conf, **app_conf): app = flask_app = CKANFlask(__name__) app.app_ctx_globals_class = CKAN_AppCtxGlobals + app.url_rule_class = CKAN_Rule # Update Flask config with the CKAN values. We use the common config # object as values might have been modified on `load_environment` @@ -50,12 +56,29 @@ def hello_world_post(): # Auto-register all blueprints defined in the `views` folder _register_core_blueprints(app) + # Set up each IBlueprint extension as a Flask Blueprint + for plugin in PluginImplementations(IBlueprint): + if hasattr(plugin, 'get_blueprint'): + app.register_extension_blueprint(plugin.get_blueprint()) + # Add a reference to the actual Flask app so it's easier to access app._wsgi_app = flask_app return app +class CKAN_Rule(Rule): + + u'''Custom Flask url_rule_class. + + We use it to be able to flag routes defined in extensions as such + ''' + + def __init__(self, *args, **kwargs): + self.ckan_core = True + super(CKAN_Rule, self).__init__(*args, **kwargs) + + class CKAN_AppCtxGlobals(_AppCtxGlobals): '''Custom Flask AppCtxGlobal class (flask.g).''' @@ -82,21 +105,46 @@ def can_handle_request(self, environ): Decides whether it can handle a request with the Flask app by matching the request environ against the route mapper - Returns (True, 'flask_app') if this is the case. - ''' + Returns (True, 'flask_app', origin) if this is the case. - # TODO: identify matching urls as core or extension. This will depend - # on how we setup routing in Flask + `origin` can be either 'core' or 'extension' depending on where + the route was defined. + ''' urls = self.url_map.bind_to_environ(environ) try: - endpoint, args = urls.match() - log.debug('Flask route match, endpoint: {0}, args: {1}'.format( - endpoint, args)) - return (True, self.app_name) + rule, args = urls.match(return_rule=True) + origin = 'core' + if hasattr(rule, 'ckan_core') and not rule.ckan_core: + origin = 'extension' + log.debug('Flask route match, endpoint: {0}, args: {1}, ' + 'origin: {2}'.format(rule.endpoint, args, origin)) + return (True, self.app_name, origin) except HTTPException: return (False, self.app_name) + def register_extension_blueprint(self, blueprint, **kwargs): + ''' + This method should be used to register blueprints that come from + extensions, so there's an opportunity to add extension-specific + options. + + Sets the rule property `ckan_core` to False, to indicate that the rule + applies to an extension route. + ''' + self.register_blueprint(blueprint, **kwargs) + + # Get the new blueprint rules + bp_rules = [v for k, v in self.url_map._rules_by_endpoint.items() + if k.startswith(blueprint.name)] + bp_rules = list(itertools.chain.from_iterable(bp_rules)) + + # This compare key will ensure the rule will be near the top. + top_compare_key = False, -100, [(-2, 0)] + for r in bp_rules: + r.ckan_core = False + r.match_compare_key = lambda: top_compare_key + def _register_core_blueprints(app): u'''Register all blueprints defined in the `views` folder diff --git a/ckan/plugins/interfaces.py b/ckan/plugins/interfaces.py index 2651c394a87..e1effd421e8 100644 --- a/ckan/plugins/interfaces.py +++ b/ckan/plugins/interfaces.py @@ -25,7 +25,8 @@ 'IFacets', 'IAuthenticator', 'ITranslation', - 'IUploader' + 'IUploader', + 'IBlueprint', ] from inspect import isclass @@ -1568,3 +1569,11 @@ def get_resource_uploader(self): :type id: string ''' + + +class IBlueprint(Interface): + + u'''Register an extension as a Flask Blueprint.''' + + def get_blueprint(self): + u'''Return a Flask Blueprint object to be registered by the app.''' diff --git a/ckan/tests/config/test_middleware.py b/ckan/tests/config/test_middleware.py index fad66fa4b6d..400f4294c13 100644 --- a/ckan/tests/config/test_middleware.py +++ b/ckan/tests/config/test_middleware.py @@ -4,6 +4,7 @@ import wsgiref from nose.tools import assert_equals, assert_not_equals, eq_ from routes import url_for +from flask import Blueprint import ckan.plugins as p import ckan.tests.helpers as helpers @@ -148,9 +149,8 @@ def test_ask_around_flask_core_route_get(self): # Even though this route is defined in Flask, there is catch all route # in Pylons for all requests to point arbitrary urls to templates with # the same name, so we get two positive answers - eq_(answers, [(True, 'flask_app'), (True, 'pylons_app', 'core')]) - # TODO: check Flask origin (core/extension) when that is in place - # (also on the following tests) + eq_(answers, [(True, 'flask_app', 'core'), + (True, 'pylons_app', 'core')]) def test_ask_around_flask_core_route_post(self): @@ -170,7 +170,8 @@ def test_ask_around_flask_core_route_post(self): # Even though this route is defined in Flask, there is catch all route # in Pylons for all requests to point arbitrary urls to templates with # the same name, so we get two positive answers - eq_(answers, [(True, 'flask_app'), (True, 'pylons_app', 'core')]) + eq_(answers, [(True, 'flask_app', 'core'), + (True, 'pylons_app', 'core')]) def test_ask_around_pylons_core_route_get(self): @@ -315,7 +316,8 @@ def test_ask_around_flask_core_and_pylons_extension_route(self): answers = app.ask_around(environ) answers = sorted(answers, key=lambda a: a[1]) - eq_(answers, [(True, 'flask_app'), (True, 'pylons_app', 'extension')]) + eq_(answers, [(True, 'flask_app', 'core'), + (True, 'pylons_app', 'extension')]) p.unload('test_routing_plugin') @@ -327,7 +329,21 @@ def test_flask_core_route_is_served_by_flask(self): eq_(res.environ['ckan.app'], 'flask_app') - # TODO: test flask extension route + def test_flask_extension_route_is_served_by_flask(self): + + app = self._get_test_app() + + # Install plugin and register its blueprint + if not p.plugin_loaded('test_routing_plugin'): + p.load('test_routing_plugin') + plugin = p.get_plugin('test_routing_plugin') + app.flask_app.register_extension_blueprint(plugin.get_blueprint()) + + res = app.get('/simple_flask') + + eq_(res.environ['ckan.app'], 'flask_app') + + p.unload('test_routing_plugin') def test_pylons_core_route_is_served_by_pylons(self): @@ -380,6 +396,7 @@ def test_flask_core_and_pylons_core_route_is_served_by_flask(self): class MockRoutingPlugin(p.SingletonPlugin): p.implements(p.IRoutes) + p.implements(p.IBlueprint) controller = 'ckan.tests.config.test_middleware:MockPylonsController' @@ -391,10 +408,14 @@ def before_map(self, _map): _map.connect('/from_pylons_extension_before_map_post_only', controller=self.controller, action='view', conditions={'method': 'POST'}) - # This one conflicts with a core Flask route + # This one conflicts with an extension Flask route _map.connect('/pylons_and_flask', controller=self.controller, action='view') + # This one conflicts with a core Flask route + _map.connect('/hello', + controller=self.controller, action='view') + return _map def after_map(self, _map): @@ -404,6 +425,22 @@ def after_map(self, _map): return _map + def get_blueprint(self): + # Create Blueprint for plugin + blueprint = Blueprint(self.name, self.__module__) + # Add plugin url rule to Blueprint object + blueprint.add_url_rule('/pylons_and_flask', 'flask_plugin_view', + flask_plugin_view) + + blueprint.add_url_rule('/simple_flask', 'flask_plugin_view', + flask_plugin_view) + + return blueprint + + +def flask_plugin_view(): + return 'Hello World, this is served from a Flask extension' + class MockPylonsController(p.toolkit.BaseController):