Skip to content

Commit

Permalink
Add support for redirects from plone.app.redirector:
Browse files Browse the repository at this point in the history
plone.rest will now handle redirects created by ``plone.app.redirector``
pretty much the same way as regular Plone.

If a redirect exists for a given URL, a ``GET`` request will be answered with
``301``, and the new location for the resource is indicated in the ``Location``
header.

Any other request method than GET (``POST``, ``PATCH``, ...) will be answered
with ``308 Permanent Redirect``. This status code instructs the client that
it should NOT switch the method, but retry (if desired) the request with the
*same* method at the new location.
  • Loading branch information
lukasgraf committed Jun 25, 2018
1 parent 0078eda commit 765c39e
Show file tree
Hide file tree
Showing 7 changed files with 534 additions and 4 deletions.
5 changes: 3 additions & 2 deletions CHANGES.rst
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
Changelog
=========

1.1.2 (unreleased)
1.2.0 (unreleased)
------------------

- Nothing changed yet.
- Add support for redirects from plone.app.redirector.
[lgraf]


1.1.1 (2018-06-22)
Expand Down
24 changes: 24 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,30 @@ Install plone.rest by adding it to your buildout::
and then running "bin/buildout"


Redirects
---------

plone.rest will handle redirects created by ``plone.app.redirector`` pretty
much the same way as regular Plone.

If a redirect exists for a given URL, a ``GET`` request will be answered with
``301``, and the new location for the resource is indicated in the ``Location``
header::

HTTP/1.1 301 Moved Permanently

Content-Type: application/json
Location: http://localhost:8080/Plone/my-folder-new-location

Any other request method than GET (``POST``, ``PATCH``, ...) will be answered
with ``308 Permanent Redirect``. This status code instructs the client that
it should NOT switch the method, but retry (if desired) the request with the
*same* method at the new location.

In practice, both the Python ``requests`` library a well as Postman seem to
honour this behavior be default.


Contribute
----------

Expand Down
147 changes: 147 additions & 0 deletions src/plone/rest/errors.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
from AccessControl import getSecurityManager
from plone.app.redirector.interfaces import IRedirectionStorage
from plone.memoize.instance import memoize
from plone.rest.interfaces import IAPIRequest
from Products.CMFCore.permissions import ManagePortal
from Products.Five.browser import BrowserView
from six.moves import urllib
from six.moves.urllib.parse import quote
from six.moves.urllib.parse import unquote
from zExceptions import NotFound
from zope.component import adapter
from zope.component import queryUtility
from zope.component.hooks import getSite

import json
Expand All @@ -13,6 +19,9 @@

@adapter(Exception, IAPIRequest)
class ErrorHandling(BrowserView):
"""This view is responsible for serializing unhandled exceptions, as well
as handling 404 Not Found errors and redirects.
"""

def __call__(self):
exception = self.context
Expand All @@ -37,6 +46,12 @@ def render_exception(self, exception):
u'message': str(exception).decode('utf-8')}

if isinstance(exception, NotFound):
# First check if a redirect from p.a.redirector exists
redirect_performed = self.attempt_redirect()
if redirect_performed:
self.request.response.setBody('', lock=1)
return

# NotFound exceptions need special handling because their
# exception message gets turned into HTML by ZPublisher
url = self.request.getURL()
Expand All @@ -55,3 +70,135 @@ def render_traceback(self, exception):

raw = '\n'.join(traceback.format_tb(exc_traceback))
return raw.strip().split('\n')

def find_redirect_if_view_or_service(self, old_path_elements, storage):
"""Find redirect for URLs like:
- http://example.com/object/namedservice/param
- http://example.com/object/@@view/param
- http://example.com/object/template
This combines the functionality of the find_redirect_if_view() and
find_redirect_if_template() methods of the original FourOhFourView into
one, and also makes it support named services.
For this to also work for named services we use a different strategy
here: Based on old_path_elements, try to find the longest stored
redirect (if any), and consider the remaining path parts the remainder
(view, template, named services plus possible params) that will need
to be appended to the new object path.
"""
if len(old_path_elements) <= 1:
return None

# Parts to the left of the split point are considered a potential
# object path, while the right part is the remainder. Starting from
# the right (longest potential obj path), we keep moving the split
# point to the left and look for shorter matches.
#
# Once we reach the point where the obj path is separated from the
# remainder, we should get a match if there's a stored redirect.
#
# ['', 'Plone', 'folder', 'item', '@@view', 'param']
# ^
splitpoint = len(old_path_elements)

while splitpoint > 1:
possible_obj_path = '/'.join(old_path_elements[:splitpoint])
remainder = old_path_elements[splitpoint:]
new_path = storage.get(possible_obj_path)

if new_path:
if new_path == possible_obj_path:
# New URL would match originally requested URL.
# Lets not cause a redirect loop.
return None
return new_path + '/' + '/'.join(remainder)

splitpoint -= 1

return None

def attempt_redirect(self):
"""Check if a redirect is needed, and perform it if necessary.
Returns True if a redirect has been performed, False otherwise.
This method is based on FourOhFourView.attempt_redirect() from
p.a.redirector. It's copied here because we want to answer redirects
to non-GET methods with status 308, but since this method locks the
response status, we wouldn't be able to change it afterwards.
"""
url = self._url()
if not url:
return False

try:
old_path_elements = self.request.physicalPathFromURL(url)
except ValueError:
return False

storage = queryUtility(IRedirectionStorage)
if storage is None:
return False

old_path = '/'.join(old_path_elements)

# First lets try with query string in cases or content migration

new_path = None

query_string = self.request.QUERY_STRING
if query_string:
new_path = storage.get("%s?%s" % (old_path, query_string))
# if we matched on the query_string we don't want to include it
# in redirect
if new_path:
query_string = ''

if not new_path:
new_path = storage.get(old_path)

# Attempt our own strategy at finding redirects for named REST
# services, views or templates.
if not new_path:
new_path = self.find_redirect_if_view_or_service(
old_path_elements, storage)

if not new_path:
return False

url = urllib.parse.urlsplit(new_path)
if url.netloc:
# External URL
# avoid double quoting
url_path = unquote(url.path)
url_path = quote(url_path)
url = urllib.parse.SplitResult(
*(url[:2] + (url_path, ) + url[3:])).geturl()
else:
url = self.request.physicalPathToURL(new_path)

# some analytics programs might use this info to track
if query_string:
url += "?" + query_string

# Answer GET requests with 301. Every other method will be answered
# with 308 Permanent Redirect, which instructs the client to NOT
# switch the method (if the original request was a POST, it should
# re-POST to the new URL from the Location header).
if self.request.method.upper() == 'GET':
status = 301
else:
status = 308

self.request.response.redirect(url, status=status, lock=1)
return True

@memoize
def _url(self):
"""Get the current, canonical URL
"""
return self.request.get('ACTUAL_URL',
self.request.get('VIRTUAL_URL', # noqa
self.request.get('URL', # noqa
None))) # noqa
22 changes: 22 additions & 0 deletions src/plone/rest/patches.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,25 @@ def __before_publishing_traverse__(self, arg1, arg2=None):
return

return self._old___before_publishing_traverse__(arg1, arg2)


PERMANENT_REDIRECT = {308: 'Permanent Redirect'}


def patch_zpublisher_status_codes(scope, unused_original, unused_replacement):
"""Add '308 Permanent Redirect' to the list of status codes the ZPublisher
knows about. Otherwise setStatus() will turn it into a 500.
"""
# Patch the forward mapping (code -> reason)
status_reasons = getattr(scope, 'status_reasons')
if 308 not in status_reasons:
status_reasons.update(PERMANENT_REDIRECT)

# Update the reverse mapping
status_codes = getattr(scope, 'status_codes')
key, val = PERMANENT_REDIRECT.items()[0]

status_codes[''.join(val.split(' ')).lower()] = key
status_codes[val.lower()] = key
status_codes[key] = key
status_codes[str(key)] = key
8 changes: 8 additions & 0 deletions src/plone/rest/patches.zcml
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,12 @@
preserveOriginal="true"
/>

<monkey:patch
description="Teach ZPublisher about status 308"
module="ZPublisher.HTTPResponse"
original="status_codes"
replacement=".patches.PERMANENT_REDIRECT"
handler=".patches.patch_zpublisher_status_codes"
/>

</configure>
Loading

0 comments on commit 765c39e

Please sign in to comment.