Skip to content

Commit

Permalink
[#3196] Allow extensions to register their blueprints
Browse files Browse the repository at this point in the history
Extensions can use the IBlueprint interface to register their own
blueprints. The main use case we have for it now is to register custom
routes, but it will also be used to register template folders, resources
etc.

Routes registered from plugins are flagged as such so theu can be
prioritized by the AppDispatcher middleware:

Flask Extension > Pylons Extension > Flask Core > Pylons Core

To do this we use a custom class for the Werkzeug rules used by Flask.

Updated the AppDispatcher tests and the test routing plugin.

All credit for these changes goes to @Brook
  • Loading branch information
amercader committed Aug 16, 2016
1 parent ee12473 commit 3f45530
Show file tree
Hide file tree
Showing 3 changed files with 110 additions and 16 deletions.
64 changes: 56 additions & 8 deletions ckan/config/middleware/flask_app.py
Expand Up @@ -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__)
Expand All @@ -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`
Expand Down Expand Up @@ -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).'''
Expand All @@ -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
Expand Down
11 changes: 10 additions & 1 deletion ckan/plugins/interfaces.py
Expand Up @@ -25,7 +25,8 @@
'IFacets',
'IAuthenticator',
'ITranslation',
'IUploader'
'IUploader',
'IBlueprint',
]

from inspect import isclass
Expand Down Expand Up @@ -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.'''
51 changes: 44 additions & 7 deletions ckan/tests/config/test_middleware.py
Expand Up @@ -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
Expand Down Expand Up @@ -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):

Expand All @@ -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):

Expand Down Expand Up @@ -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')

Expand All @@ -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):

Expand Down Expand Up @@ -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'

Expand All @@ -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):
Expand All @@ -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):

Expand Down

0 comments on commit 3f45530

Please sign in to comment.