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:
parent
264d6f8288
commit
447ae50497
@ -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
|
||||||
|
@ -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:
|
||||||
|
70
storyboard/api/middleware/redirect_middleware.py
Normal file
70
storyboard/api/middleware/redirect_middleware.py
Normal 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
|
120
storyboard/tests/api/middleware/test_redirect_middleware.py
Normal file
120
storyboard/tests/api/middleware/test_redirect_middleware.py
Normal 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'])
|
Loading…
Reference in New Issue
Block a user