Redirect Browsers from API to Client

This middleware will automatically redirect any clients that
express a desire for text/html content away from the API and to
the configured webclient. It assumes that the webclient will have
an identical URL structure after the API root, or have sane 404
handling.

This feature is being added to simplify generating a URL in email.
We point the user at the API, and the API will redirect the user
to the appropriate location. If, instead, the client sends a
different accept header, then this will not trigger and return
the raw data instead.

Example:

GET https://storyboard.openstack.org/api/v1/projects
303 https://storyboard.openstack.org/#!/projects

Future work on this feature will intelligently check for search
engine bots, and return a scrapeable html page for SEO.

Change-Id: Id98e12f85ce1523ab3982d070c438583d51ce9cb
This commit is contained in:
Michael Krotscheck 2015-02-12 16:50:48 -08:00
parent 264d6f8288
commit 447ae50497
4 changed files with 207 additions and 4 deletions

View File

@ -37,6 +37,12 @@ lock_path = $state_path/lock
# Port the bind the API server to # Port the bind the API server to
# bind_port = 8080 # bind_port = 8080
# The default web client. This is the URL to which a client, presenting an
# Accepts: text/html header, will be redirected to when browsing the API. It
# is also used for email URL resolution, so we highly recommend that you set
# this to the host and protocol of your own storyboard server.
# default_client_url = https://storyboard.openstack.org/#!
# Enable notifications. This feature drives deferred processing, reporting, # Enable notifications. This feature drives deferred processing, reporting,
# and subscriptions. # and subscriptions.
# enable_notifications = True # enable_notifications = True

View File

@ -23,6 +23,8 @@ from wsgiref import simple_server
from storyboard.api import config as api_config from storyboard.api import config as api_config
from storyboard.api.middleware.cors_middleware import CORSMiddleware from storyboard.api.middleware.cors_middleware import CORSMiddleware
from storyboard.api.middleware import redirect_middleware
from storyboard.api.middleware import session_hook from storyboard.api.middleware import session_hook
from storyboard.api.middleware import token_middleware from storyboard.api.middleware import token_middleware
from storyboard.api.middleware import user_id_hook from storyboard.api.middleware import user_id_hook
@ -52,7 +54,10 @@ API_OPTS = [
help='API port'), help='API port'),
cfg.BoolOpt('enable_notifications', cfg.BoolOpt('enable_notifications',
default=False, default=False,
help='Enable Notifications') help='Enable Notifications'),
cfg.StrOpt('default_client_url',
default='https://storyboard.openstack.org/#!',
help='The URL of the default web client.')
] ]
CORS_OPTS = [ CORS_OPTS = [
cfg.ListOpt('allowed_origins', cfg.ListOpt('allowed_origins',
@ -115,6 +120,8 @@ def setup_app(pecan_config=None):
) )
app = token_middleware.AuthTokenMiddleware(app) app = token_middleware.AuthTokenMiddleware(app)
app = redirect_middleware. \
BrowserRedirectMiddleware(app, client_root_url=CONF.default_client_url)
# Setup CORS # Setup CORS
if CONF.cors.allowed_origins: if CONF.cors.allowed_origins:

View File

@ -0,0 +1,70 @@
# Copyright (c) 2015 Hewlett-Packard Development Company, L.P.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
# implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import re
from webob.acceptparse import Accept
class BrowserRedirectMiddleware(object):
# A list of HTML Headers that may come from browsers.
html_headers = [
'text/html',
'application/xhtml+xml'
]
def __init__(self, app, client_root_url='/'):
"""Build an HTTP redirector, with the initial assumption that the
client is installed on the same host as this wsgi app.
:param app The WSGI app to wrap.
:param client_root_url The root URL of the redirect target's path.
"""
self.app = app
self.client_root_url = client_root_url
def __call__(self, env, start_response):
# We only care about GET methods.
if env['REQUEST_METHOD'] == 'GET' and 'HTTP_ACCEPT' in env:
# Iterate over the headers.
for type, quality in Accept.parse(env['HTTP_ACCEPT']):
# Only accept quality 1 headers, anything less
# implies that the client prefers something else.
if quality == 1 and type in self.html_headers:
# Build the redirect URL and redirect if successful
redirect_to = self._build_redirect_url(env['PATH_INFO'])
if redirect_to:
start_response("303 See Other",
[('Location', redirect_to)])
return []
# Otherwise, break out of the whole loop and let the
# default handler deal with it.
break
return self.app(env, start_response)
def _build_redirect_url(self, path):
# To map to the client, we are assuming that the API adheres to a URL
# pattern of "/superfluous_prefix/v1/other_things. We strip out
# anything up to and including /v1, and use the rest as our redirect
# fragment. Note that this middleware makes no assumption about #!
# navigation, as it is feasible that true HTML5 history support is
# available on the client.
match = re.search('\/v1(\/.*$)', path)
if match:
return self.client_root_url + match.group(1)
else:
return None

View File

@ -0,0 +1,120 @@
# Copyright (c) 2015 Hewlett-Packard Development Company, L.P.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
import six
from oslo.config import cfg
from storyboard.tests import base
CONF = cfg.CONF
class TestRedirectMiddleware(base.FunctionalTest):
# Map of API -> Client urls that we're expecting.
uri_mappings = {
'/v1/projects': 'https://storyboard.openstack.org/#!/projects',
'/v1/stories/22': 'https://storyboard.openstack.org/#!/stories/22',
'/v1/project_groups/2': 'https://storyboard.openstack.org/'
'#!/project_groups/2'
}
def test_valid_results(self):
"""Assert that the expected URI mappings are returned."""
headers = {
'Accept': 'text/html;q=1'
}
for request_uri, redirect_uri in six.iteritems(self.uri_mappings):
response = self.app.get(request_uri,
headers=headers,
expect_errors=True)
self.assertEqual(303, response.status_code)
self.assertEqual(redirect_uri, response.headers['Location'])
def test_valid_results_as_post_put_delete(self):
"""Assert that POST, PUT, and DELETE methods are passed through to
the API.
"""
headers = {
'Accept': 'text/html;q=1'
}
for request_uri, redirect_uri in six.iteritems(self.uri_mappings):
response = self.app.post(request_uri, headers=headers,
expect_errors=True)
self.assertNotEqual(303, response.status_code)
self.assertNotIn('Location', response.headers)
response = self.app.put(request_uri, headers=headers,
expect_errors=True)
self.assertNotEqual(303, response.status_code)
self.assertNotIn('Location', response.headers)
response = self.app.delete(request_uri, headers=headers,
expect_errors=True)
self.assertNotEqual(303, response.status_code)
self.assertNotIn('Location', response.headers)
def test_graceful_accepts_header(self):
"""If the client prefers some other content type, make sure we
respect that.
"""
headers = {
'Accept': 'text/html;q=.9,application/json;q=1'
}
for request_uri, redirect_uri in six.iteritems(self.uri_mappings):
response = self.app.get(request_uri,
headers=headers,
expect_errors=True)
self.assertNotEqual(303, response.status_code)
self.assertNotIn('Location', response.headers)
def test_with_browser_useragent(self):
"""Future protection test. Make sure that no other code accidentally
gets in the way of browsers being redirected (such as search engine
bot response handling). This is intended to be a canary for
unexpected changes, rather than a comprehensive test for all possible
browsers.
"""
user_agents = [
# Chrome 41
'Mozilla/5.0 (Windows NT 6.1) AppleWebKit/537.36 (KHTML,'
' like Gecko) Chrome/41.0.2228.0 Safari/537.36',
# Firefox 36
'Mozilla/5.0 (Windows NT 6.3; rv:36.0) Gecko/20100101'
' Firefox/36.0',
# IE10
'Mozilla/5.0 (Windows NT 6.1; WOW64; Trident/7.0; AS; rv:11.0)'
' like Gecko'
]
for request_uri, redirect_uri in six.iteritems(self.uri_mappings):
for user_agent in user_agents:
headers = {
'User-Agent': user_agent,
'Accept': 'text/html;q=1'
}
response = self.app.get(request_uri,
headers=headers,
expect_errors=True)
self.assertEqual(303, response.status_code)
self.assertEqual(redirect_uri, response.headers['Location'])