diff --git a/sanic/config.py b/sanic/config.py index 3fbc157f..7b7e170d 100644 --- a/sanic/config.py +++ b/sanic/config.py @@ -32,6 +32,7 @@ DEFAULT_CONFIG = { "REAL_IP_HEADER": None, "PROXIES_COUNT": None, "FORWARDED_FOR_HEADER": "X-Forwarded-For", + "FALLBACK_ERROR_FORMAT": "html", } diff --git a/sanic/errorpages.py b/sanic/errorpages.py index 2d17cbde..a4d6b494 100644 --- a/sanic/errorpages.py +++ b/sanic/errorpages.py @@ -1,13 +1,283 @@ import sys +import typing as t +from functools import partial from traceback import extract_tb -from sanic.exceptions import SanicException +from sanic.exceptions import InvalidUsage, SanicException from sanic.helpers import STATUS_CODES -from sanic.response import html +from sanic.request import Request +from sanic.response import HTTPResponse, html, json, text -# Here, There Be Dragons (custom HTML formatting to follow) +try: + from ujson import dumps + + dumps = partial(dumps, escape_forward_slashes=False) +except ImportError: # noqa + from json import dumps # type: ignore + + +FALLBACK_TEXT = ( + "The server encountered an internal error and " + "cannot complete your request." +) +FALLBACK_STATUS = 500 + + +class BaseRenderer: + def __init__(self, request, exception, debug): + self.request = request + self.exception = exception + self.debug = debug + + @property + def headers(self): + if isinstance(self.exception, SanicException): + return getattr(self.exception, "headers", {}) + return {} + + @property + def status(self): + if isinstance(self.exception, SanicException): + return getattr(self.exception, "status_code", FALLBACK_STATUS) + return FALLBACK_STATUS + + @property + def text(self): + if self.debug or isinstance(self.exception, SanicException): + return str(self.exception) + return FALLBACK_TEXT + + @property + def title(self): + status_text = STATUS_CODES.get(self.status, b"Error Occurred").decode() + return f"{self.status} — {status_text}" + + def render(self): + output = ( + self.full + if self.debug and not getattr(self.exception, "quiet", False) + else self.minimal + ) + return output() + + def minimal(self): # noqa + raise NotImplementedError + + def full(self): # noqa + raise NotImplementedError + + +class HTMLRenderer(BaseRenderer): + TRACEBACK_STYLE = """ + html { font-family: sans-serif } + h2 { color: #888; } + .tb-wrapper p { margin: 0 } + .frame-border { margin: 1rem } + .frame-line > * { padding: 0.3rem 0.6rem } + .frame-line { margin-bottom: 0.3rem } + .frame-code { font-size: 16px; padding-left: 4ch } + .tb-wrapper { border: 1px solid #eee } + .tb-header { background: #eee; padding: 0.3rem; font-weight: bold } + .frame-descriptor { background: #e2eafb; font-size: 14px } + """ + TRACEBACK_WRAPPER_HTML = ( + "
{exc_name}: {exc_value}
" + "
{frame_html}
" + ) + TRACEBACK_BORDER = ( + "
" + "The above exception was the direct cause of the following exception:" + "
" + ) + TRACEBACK_LINE_HTML = ( + "
" + "

" + "File {0.filename}, line {0.lineno}, " + "in {0.name}" + "

{0.line}" + "

" + ) + OUTPUT_HTML = ( + "" + "{title}\n" + "\n" + "

{title}

{text}\n" + "{body}" + ) + + def full(self): + return html( + self.OUTPUT_HTML.format( + title=self.title, + text=self.text, + style=self.TRACEBACK_STYLE, + body=self._generate_body(), + ), + status=self.status, + ) + + def minimal(self): + return html( + self.OUTPUT_HTML.format( + title=self.title, + text=self.text, + style=self.TRACEBACK_STYLE, + body="", + ), + status=self.status, + headers=self.headers, + ) + + @property + def text(self): + return escape(super().text) + + @property + def title(self): + return escape(f"⚠️ {super().title}") + + def _generate_body(self): + _, exc_value, __ = sys.exc_info() + exceptions = [] + while exc_value: + exceptions.append(self._format_exc(exc_value)) + exc_value = exc_value.__cause__ + + traceback_html = self.TRACEBACK_BORDER.join(reversed(exceptions)) + appname = escape(self.request.app.name) + name = escape(self.exception.__class__.__name__) + value = escape(self.exception) + path = escape(self.request.path) + lines = [ + f"

Traceback of {appname} (most recent call last):

", + f"{traceback_html}", + "

", + f"{name}: {value} while handling path {path}", + "

", + ] + return "\n".join(lines) + + def _format_exc(self, exc): + frames = extract_tb(exc.__traceback__) + frame_html = "".join( + self.TRACEBACK_LINE_HTML.format(frame) for frame in frames + ) + return self.TRACEBACK_WRAPPER_HTML.format( + exc_name=escape(exc.__class__.__name__), + exc_value=escape(exc), + frame_html=frame_html, + ) + + +class TextRenderer(BaseRenderer): + OUTPUT_TEXT = "{title}\n{bar}\n{text}\n\n{body}" + SPACER = " " + + def full(self): + return text( + self.OUTPUT_TEXT.format( + title=self.title, + text=self.text, + bar=("=" * len(self.title)), + body=self._generate_body(), + ), + status=self.status, + ) + + def minimal(self): + return text( + self.OUTPUT_TEXT.format( + title=self.title, + text=self.text, + bar=("=" * len(self.title)), + body="", + ), + status=self.status, + headers=self.headers, + ) + + @property + def title(self): + return f"⚠️ {super().title}" + + def _generate_body(self): + _, exc_value, __ = sys.exc_info() + exceptions = [] + + # traceback_html = self.TRACEBACK_BORDER.join(reversed(exceptions)) + lines = [ + f"{self.exception.__class__.__name__}: {self.exception} while " + f"handling path {self.request.path}", + f"Traceback of {self.request.app.name} (most recent call last):\n", + ] + + while exc_value: + exceptions.append(self._format_exc(exc_value)) + exc_value = exc_value.__cause__ + + return "\n".join(lines + exceptions[::-1]) + + def _format_exc(self, exc): + frames = "\n\n".join( + [ + f"{self.SPACER * 2}File {frame.filename}, " + f"line {frame.lineno}, in " + f"{frame.name}\n{self.SPACER * 2}{frame.line}" + for frame in extract_tb(exc.__traceback__) + ] + ) + return f"{self.SPACER}{exc.__class__.__name__}: {exc}\n{frames}" + + +class JSONRenderer(BaseRenderer): + def full(self): + output = self._generate_output(full=True) + return json(output, status=self.status, dumps=dumps) + + def minimal(self): + output = self._generate_output(full=False) + return json(output, status=self.status, dumps=dumps) + + def _generate_output(self, *, full): + output = { + "description": self.title, + "status": self.status, + "message": self.text, + } + + if full: + _, exc_value, __ = sys.exc_info() + exceptions = [] + + while exc_value: + exceptions.append( + { + "type": exc_value.__class__.__name__, + "exception": str(exc_value), + "frames": [ + { + "file": frame.filename, + "line": frame.lineno, + "name": frame.name, + "src": frame.line, + } + for frame in extract_tb(exc_value.__traceback__) + ], + } + ) + exc_value = exc_value.__cause__ + + output["path"] = self.request.path + output["args"] = self.request.args + output["exceptions"] = exceptions[::-1] + + return output + + @property + def title(self): + return STATUS_CODES.get(self.status, b"Error Occurred").decode() def escape(text): @@ -15,103 +285,46 @@ def escape(text): return f"{text}".replace("&", "&").replace("<", "<") -def exception_response(request, exception, debug): - status = 500 - text = ( - "The server encountered an internal error " - "and cannot complete your request." - ) +RENDERERS_BY_CONFIG = { + "html": HTMLRenderer, + "json": JSONRenderer, + "text": TextRenderer, +} - headers = {} - if isinstance(exception, SanicException): - text = f"{exception}" - status = getattr(exception, "status_code", status) - headers = getattr(exception, "headers", headers) - elif debug: - text = f"{exception}" - - status_text = STATUS_CODES.get(status, b"Error Occurred").decode() - title = escape(f"{status} — {status_text}") - text = escape(text) - - if debug and not getattr(exception, "quiet", False): - return html( - f"{title}" - f"\n" - f"

⚠️ {title}

{text}\n" - f"{_render_traceback_html(request, exception)}", - status=status, - ) - - # Keeping it minimal with trailing newline for pretty curl/console output - return html( - f"{title}" - "\n" - f"

⚠️ {title}

{text}\n", - status=status, - headers=headers, - ) +RENDERERS_BY_CONTENT_TYPE = { + "multipart/form-data": HTMLRenderer, + "application/json": JSONRenderer, + "text/plain": TextRenderer, +} -def _render_exception(exception): - frames = extract_tb(exception.__traceback__) - frame_html = "".join(TRACEBACK_LINE_HTML.format(frame) for frame in frames) - return TRACEBACK_WRAPPER_HTML.format( - exc_name=escape(exception.__class__.__name__), - exc_value=escape(exception), - frame_html=frame_html, - ) +def exception_response( + request: Request, + exception: Exception, + debug: bool, + renderer: t.Type[t.Optional[BaseRenderer]] = None, +) -> HTTPResponse: + """Render a response for the default FALLBACK exception handler""" + if not renderer: + renderer = HTMLRenderer -def _render_traceback_html(request, exception): - exc_type, exc_value, tb = sys.exc_info() - exceptions = [] - while exc_value: - exceptions.append(_render_exception(exc_value)) - exc_value = exc_value.__cause__ + if request: + if request.app.config.FALLBACK_ERROR_FORMAT == "auto": + try: + renderer = JSONRenderer if request.json else HTMLRenderer + except InvalidUsage: + renderer = HTMLRenderer - traceback_html = TRACEBACK_BORDER.join(reversed(exceptions)) - appname = escape(request.app.name) - name = escape(exception.__class__.__name__) - value = escape(exception) - path = escape(request.path) - return ( - f"

Traceback of {appname} (most recent call last):

" - f"{traceback_html}" - "

" - f"{name}: {value} while handling path {path}" - ) + content_type, *_ = request.headers.get( + "content-type", "" + ).split(";") + renderer = RENDERERS_BY_CONTENT_TYPE.get( + content_type, renderer + ) + else: + render_format = request.app.config.FALLBACK_ERROR_FORMAT + renderer = RENDERERS_BY_CONFIG.get(render_format, renderer) - -TRACEBACK_STYLE = """ - html { font-family: sans-serif } - h2 { color: #888; } - .tb-wrapper p { margin: 0 } - .frame-border { margin: 1rem } - .frame-line > * { padding: 0.3rem 0.6rem } - .frame-line { margin-bottom: 0.3rem } - .frame-code { font-size: 16px; padding-left: 4ch } - .tb-wrapper { border: 1px solid #eee } - .tb-header { background: #eee; padding: 0.3rem; font-weight: bold } - .frame-descriptor { background: #e2eafb; font-size: 14px } -""" - -TRACEBACK_WRAPPER_HTML = ( - "

{exc_name}: {exc_value}
" - "
{frame_html}
" -) - -TRACEBACK_BORDER = ( - "
" - "The above exception was the direct cause of the following exception:" - "
" -) - -TRACEBACK_LINE_HTML = ( - "
" - "

" - "File {0.filename}, line {0.lineno}, " - "in {0.name}" - "

{0.line}" - "

" -) + renderer = t.cast(t.Type[BaseRenderer], renderer) + return renderer(request, exception, debug).render() diff --git a/tests/test_app.py b/tests/test_app.py index dab46061..c9c44c39 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -126,7 +126,7 @@ def test_app_handle_request_handler_is_none(app, monkeypatch): def handler(request): return text("test") - request, response = app.test_client.get("/test") + _, response = app.test_client.get("/test") assert ( "'None' was returned while requesting a handler from the router" diff --git a/tests/test_errorpages.py b/tests/test_errorpages.py new file mode 100644 index 00000000..495c764f --- /dev/null +++ b/tests/test_errorpages.py @@ -0,0 +1,86 @@ +import pytest + +from sanic import Sanic +from sanic.errorpages import exception_response +from sanic.exceptions import NotFound +from sanic.request import Request +from sanic.response import HTTPResponse + + +@pytest.fixture +def app(): + app = Sanic("error_page_testing") + + @app.route("/error", methods=["GET", "POST"]) + def err(request): + raise Exception("something went wrong") + + return app + + +@pytest.fixture +def fake_request(app): + return Request(b"/foobar", {}, "1.1", "GET", None, app) + + +@pytest.mark.parametrize( + "fallback,content_type, exception, status", + ( + (None, "text/html; charset=utf-8", Exception, 500), + ("html", "text/html; charset=utf-8", Exception, 500), + ("auto", "text/html; charset=utf-8", Exception, 500), + ("text", "text/plain; charset=utf-8", Exception, 500), + ("json", "application/json", Exception, 500), + (None, "text/html; charset=utf-8", NotFound, 404), + ("html", "text/html; charset=utf-8", NotFound, 404), + ("auto", "text/html; charset=utf-8", NotFound, 404), + ("text", "text/plain; charset=utf-8", NotFound, 404), + ("json", "application/json", NotFound, 404), + ), +) +def test_should_return_html_valid_setting( + fake_request, fallback, content_type, exception, status +): + if fallback: + fake_request.app.config.FALLBACK_ERROR_FORMAT = fallback + + try: + raise exception("bad stuff") + except Exception as e: + response = exception_response(fake_request, e, True) + + assert isinstance(response, HTTPResponse) + assert response.status == status + assert response.content_type == content_type + + +def test_auto_fallback_with_data(app): + app.config.FALLBACK_ERROR_FORMAT = "auto" + + _, response = app.test_client.get("/error") + assert response.status == 500 + assert response.content_type == "text/html; charset=utf-8" + + _, response = app.test_client.post("/error", json={"foo": "bar"}) + assert response.status == 500 + assert response.content_type == "application/json" + + _, response = app.test_client.post("/error", data={"foo": "bar"}) + assert response.status == 500 + assert response.content_type == "text/html; charset=utf-8" + + +def test_auto_fallback_with_content_type(app): + app.config.FALLBACK_ERROR_FORMAT = "auto" + + _, response = app.test_client.get( + "/error", headers={"content-type": "application/json"} + ) + assert response.status == 500 + assert response.content_type == "application/json" + + _, response = app.test_client.get( + "/error", headers={"content-type": "text/plain"} + ) + assert response.status == 500 + assert response.content_type == "text/plain; charset=utf-8"