Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature: JSON exceptions #2489

Merged
merged 14 commits into from
Apr 14, 2016
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGES.txt
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
unreleased
==========

- Pyramid HTTPExceptions will now take into account the best match for the
clients Accept header, and depending on what is requested will return
text/html, application/json or text/plain. The default for */* is still
text/html, but if application/json is explicitly mentioned it will now
receive a valid JSON response. See:
https://github.com/Pylons/pyramid/pull/2489

- (Deprecation) Support for Python 3.3 will be removed in Pyramid 1.8.
https://github.com/Pylons/pyramid/issues/2477

Expand Down
50 changes: 42 additions & 8 deletions pyramid/httpexceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,12 +123,14 @@
field. Reflecting this, these subclasses have one additional keyword argument:
``location``, which indicates the location to which to redirect.
"""
import json

from string import Template

from zope.interface import implementer

from webob import html_escape as _html_escape
from webob.acceptparse import MIMEAccept

from pyramid.compat import (
class_types,
Expand Down Expand Up @@ -214,7 +216,7 @@ class HTTPException(Response, Exception):
empty_body = False

def __init__(self, detail=None, headers=None, comment=None,
body_template=None, **kw):
body_template=None, json_formatter=None, **kw):
status = '%s %s' % (self.code, self.title)
Response.__init__(self, status=status, **kw)
Exception.__init__(self, detail)
Expand All @@ -225,6 +227,8 @@ def __init__(self, detail=None, headers=None, comment=None,
if body_template is not None:
self.body_template = body_template
self.body_template_obj = Template(body_template)
if json_formatter is not None:
self._json_formatter = json_formatter

if self.empty_body:
del self.content_type
Expand All @@ -233,18 +237,48 @@ def __init__(self, detail=None, headers=None, comment=None,
def __str__(self):
return self.detail or self.explanation

def _json_formatter(self, status, body, title, environ):
return {'message': body,
'code': status,
'title': self.title}

def prepare(self, environ):
if not self.body and not self.empty_body:
html_comment = ''
comment = self.comment or ''
accept = environ.get('HTTP_ACCEPT', '')
if accept and 'html' in accept or '*/*' in accept:
accept_value = environ.get('HTTP_ACCEPT', '')
accept = MIMEAccept(accept_value)
# Attempt to match text/html or application/json, if those don't
# match, we will fall through to defaulting to text/plain
match = accept.best_match(['text/html', 'application/json'])

if match == 'text/html':
self.content_type = 'text/html'
escape = _html_escape
page_template = self.html_template_obj
br = '<br/>'
if comment:
html_comment = '<!-- %s -->' % escape(comment)
elif match == 'application/json':
self.content_type = 'application/json'
self.charset = None
escape = _no_escape
br = '\n'
if comment:
html_comment = escape(comment)

class JsonPageTemplate(object):
def __init__(self, excobj):
self.excobj = excobj

def substitute(self, status, body):
jsonbody = self.excobj._json_formatter(
status=status,
body=body, title=self.excobj.title,
environ=environ)
return json.dumps(jsonbody)

page_template = JsonPageTemplate(self)
else:
self.content_type = 'text/plain'
escape = _no_escape
Expand All @@ -253,11 +287,11 @@ def prepare(self, environ):
if comment:
html_comment = escape(comment)
args = {
'br':br,
'br': br,
'explanation': escape(self.explanation),
'detail': escape(self.detail or ''),
'comment': escape(comment),
'html_comment':html_comment,
'html_comment': html_comment,
}
body_tmpl = self.body_template_obj
if HTTPException.body_template_obj is not body_tmpl:
Expand All @@ -274,7 +308,7 @@ def prepare(self, environ):
body = body_tmpl.substitute(args)
page = page_template.substitute(status=self.status, body=body)
if isinstance(page, text_type):
page = page.encode(self.charset)
page = page.encode(self.charset if self.charset else 'UTF-8')
self.app_iter = [page]
self.body = page

Expand Down Expand Up @@ -1001,8 +1035,8 @@ class HTTPInternalServerError(HTTPServerError):
code = 500
title = 'Internal Server Error'
explanation = (
'The server has either erred or is incapable of performing '
'the requested operation.')
'The server has either erred or is incapable of performing '
'the requested operation.')

class HTTPNotImplemented(HTTPServerError):
"""
Expand Down
121 changes: 104 additions & 17 deletions pyramid/tests/test_httpexceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,9 @@ def test_status_201(self):
self.assertTrue(isinstance(self._callFUT(201), HTTPCreated))

def test_extra_kw(self):
resp = self._callFUT(404, headers=[('abc', 'def')])
resp = self._callFUT(404, headers=[('abc', 'def')])
self.assertEqual(resp.headers['abc'], 'def')

class Test_default_exceptionresponse_view(unittest.TestCase):
def _callFUT(self, context, request):
from pyramid.httpexceptions import default_exceptionresponse_view
Expand Down Expand Up @@ -129,7 +129,7 @@ def test_ctor_extends_headers(self):
def test_ctor_sets_body_template_obj(self):
exc = self._makeOne(body_template='${foo}')
self.assertEqual(
exc.body_template_obj.substitute({'foo':'foo'}), 'foo')
exc.body_template_obj.substitute({'foo': 'foo'}), 'foo')

def test_ctor_with_empty_body(self):
cls = self._getTargetSubclass(empty_body=True)
Expand Down Expand Up @@ -160,7 +160,7 @@ def test_ctor_with_body_sets_default_app_iter_html(self):
self.assertTrue(b'200 OK' in body)
self.assertTrue(b'explanation' in body)
self.assertTrue(b'detail' in body)

def test_ctor_with_body_sets_default_app_iter_text(self):
cls = self._getTargetSubclass()
exc = cls('detail')
Expand All @@ -173,7 +173,7 @@ def test__str__detail(self):
exc = self._makeOne()
exc.detail = 'abc'
self.assertEqual(str(exc), 'abc')

def test__str__explanation(self):
exc = self._makeOne()
exc.explanation = 'def'
Expand Down Expand Up @@ -212,6 +212,9 @@ def test__default_app_iter_no_comment_plain(self):
environ = _makeEnviron()
start_response = DummyStartResponse()
body = list(exc(environ, start_response))[0]
for header in start_response.headerlist:
if header[0] == 'Content-Type':
self.assertEqual(header[1], 'text/plain; charset=UTF-8')
self.assertEqual(body, b'200 OK\n\nexplanation\n\n\n\n\n')

def test__default_app_iter_with_comment_plain(self):
Expand All @@ -220,26 +223,78 @@ def test__default_app_iter_with_comment_plain(self):
environ = _makeEnviron()
start_response = DummyStartResponse()
body = list(exc(environ, start_response))[0]
for header in start_response.headerlist:
if header[0] == 'Content-Type':
self.assertEqual(header[1], 'text/plain; charset=UTF-8')
self.assertEqual(body, b'200 OK\n\nexplanation\n\n\n\ncomment\n')

def test__default_app_iter_no_comment_html(self):
cls = self._getTargetSubclass()
exc = cls()
environ = _makeEnviron()
start_response = DummyStartResponse()
body = list(exc(environ, start_response))[0]
for header in start_response.headerlist:
if header[0] == 'Content-Type':
self.assertEqual(header[1], 'text/plain; charset=UTF-8')
self.assertFalse(b'<!-- ' in body)

def test__default_app_iter_with_comment_html(self):
def test__content_type(self):
cls = self._getTargetSubclass()
exc = cls(comment='comment & comment')
exc = cls()
environ = _makeEnviron()
start_response = DummyStartResponse()
exc(environ, start_response)
for header in start_response.headerlist:
if header[0] == 'Content-Type':
self.assertEqual(header[1], 'text/plain; charset=UTF-8')

def test__content_type_default_is_html(self):
cls = self._getTargetSubclass()
exc = cls()
environ = _makeEnviron()
environ['HTTP_ACCEPT'] = '*/*'
start_response = DummyStartResponse()
exc(environ, start_response)
for header in start_response.headerlist:
if header[0] == 'Content-Type':
self.assertEqual(header[1], 'text/html; charset=UTF-8')

def test__content_type_text_html(self):
cls = self._getTargetSubclass()
exc = cls()
environ = _makeEnviron()
environ['HTTP_ACCEPT'] = 'text/html'
start_response = DummyStartResponse()
exc(environ, start_response)
for header in start_response.headerlist:
if header[0] == 'Content-Type':
self.assertEqual(header[1], 'text/html; charset=UTF-8')

def test__content_type_application_json(self):
cls = self._getTargetSubclass()
exc = cls()
environ = _makeEnviron()
environ['HTTP_ACCEPT'] = 'application/json'
start_response = DummyStartResponse()
exc(environ, start_response)
for header in start_response.headerlist:
if header[0] == 'Content-Type':
self.assertEqual(header[1], 'application/json')

def test__default_app_iter_with_comment_ampersand(self):
cls = self._getTargetSubclass()
exc = cls(comment='comment & comment')
environ = _makeEnviron()
environ['HTTP_ACCEPT'] = 'text/html'
start_response = DummyStartResponse()
body = list(exc(environ, start_response))[0]
for header in start_response.headerlist:
if header[0] == 'Content-Type':
self.assertEqual(header[1], 'text/html; charset=UTF-8')
self.assertTrue(b'<!-- comment &amp; comment -->' in body)

def test__default_app_iter_with_comment_html2(self):
def test__default_app_iter_with_comment_html(self):
cls = self._getTargetSubclass()
exc = cls(comment='comment & comment')
environ = _makeEnviron()
Expand All @@ -248,6 +303,38 @@ def test__default_app_iter_with_comment_html2(self):
body = list(exc(environ, start_response))[0]
self.assertTrue(b'<!-- comment &amp; comment -->' in body)

def test__default_app_iter_with_comment_json(self):
cls = self._getTargetSubclass()
exc = cls(comment='comment & comment')
environ = _makeEnviron()
environ['HTTP_ACCEPT'] = 'application/json'
start_response = DummyStartResponse()
body = list(exc(environ, start_response))[0]
import json
retval = json.loads(body.decode('UTF-8'))
self.assertEqual(retval['code'], '200 OK')
self.assertEqual(retval['title'], 'OK')

def test__default_app_iter_with_custom_json(self):
def json_formatter(status, body, title, environ):
return {'message': body,
'code': status,
'title': title,
'custom': environ['CUSTOM_VARIABLE']
}
cls = self._getTargetSubclass()
exc = cls(comment='comment', json_formatter=json_formatter)
environ = _makeEnviron()
environ['HTTP_ACCEPT'] = 'application/json'
environ['CUSTOM_VARIABLE'] = 'custom!'
start_response = DummyStartResponse()
body = list(exc(environ, start_response))[0]
import json
retval = json.loads(body.decode('UTF-8'))
self.assertEqual(retval['code'], '200 OK')
self.assertEqual(retval['title'], 'OK')
self.assertEqual(retval['custom'], 'custom!')

def test_custom_body_template(self):
cls = self._getTargetSubclass()
exc = cls(body_template='${REQUEST_METHOD}')
Expand All @@ -261,7 +348,8 @@ def test_custom_body_template_with_custom_variable_doesnt_choke(self):
exc = cls(body_template='${REQUEST_METHOD}')
environ = _makeEnviron()
class Choke(object):
def __str__(self): raise ValueError
def __str__(self): # pragma nocover
raise ValueError
environ['gardentheory.user'] = Choke()
start_response = DummyStartResponse()
body = list(exc(environ, start_response))[0]
Expand Down Expand Up @@ -293,7 +381,7 @@ def _doit(self, content_type):
self.assertTrue(bytes_(exc.status) in result)
L.append(result)
self.assertEqual(len(L), len(status_map))

def test_it_plain(self):
self._doit('text/plain')

Expand Down Expand Up @@ -367,12 +455,11 @@ class DummyStartResponse(object):
def __call__(self, status, headerlist):
self.status = status
self.headerlist = headerlist

def _makeEnviron(**kw):
environ = {'REQUEST_METHOD':'GET',
'wsgi.url_scheme':'http',
'SERVER_NAME':'localhost',
'SERVER_PORT':'80'}
environ = {'REQUEST_METHOD': 'GET',
'wsgi.url_scheme': 'http',
'SERVER_NAME': 'localhost',
'SERVER_PORT': '80'}
environ.update(kw)
return environ