Smarter auto fallback (#2162)
* Smarter auto fallback * remove config from blueprints * Add tests for error formatting * Add check for proper format * Fix some tests * Add some tests * docstring * Add accept matching * Add some more tests on matching * Fix contains bug, earlier return on MediaType eq * Add matching flags for wildcards * Add mathing controls to accept * Cleanup dev cruft * Add cleanup and resolve OSError relating to test implementation * Fix test * Fix some typos
This commit is contained in:
@@ -109,6 +109,7 @@ def sanic_router(app):
|
||||
# noinspection PyProtectedMember
|
||||
def _setup(route_details: tuple) -> Tuple[Router, tuple]:
|
||||
router = Router()
|
||||
router.ctx.app = app
|
||||
added_router = []
|
||||
for method, route in route_details:
|
||||
try:
|
||||
|
||||
@@ -20,4 +20,4 @@ def test_bad_request_response(app):
|
||||
|
||||
app.run(host="127.0.0.1", port=42101, debug=False)
|
||||
assert lines[0] == b"HTTP/1.1 400 Bad Request\r\n"
|
||||
assert b"Bad Request" in lines[-1]
|
||||
assert b"Bad Request" in lines[-2]
|
||||
|
||||
@@ -3,7 +3,12 @@ from pytest import raises
|
||||
from sanic.app import Sanic
|
||||
from sanic.blueprint_group import BlueprintGroup
|
||||
from sanic.blueprints import Blueprint
|
||||
from sanic.exceptions import Forbidden, InvalidUsage, SanicException, ServerError
|
||||
from sanic.exceptions import (
|
||||
Forbidden,
|
||||
InvalidUsage,
|
||||
SanicException,
|
||||
ServerError,
|
||||
)
|
||||
from sanic.request import Request
|
||||
from sanic.response import HTTPResponse, text
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import pytest
|
||||
|
||||
from sanic import Sanic
|
||||
from sanic.errorpages import exception_response
|
||||
from sanic.exceptions import NotFound
|
||||
from sanic.errorpages import HTMLRenderer, exception_response
|
||||
from sanic.exceptions import NotFound, SanicException
|
||||
from sanic.request import Request
|
||||
from sanic.response import HTTPResponse
|
||||
from sanic.response import HTTPResponse, html, json, text
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -20,7 +20,7 @@ def app():
|
||||
|
||||
@pytest.fixture
|
||||
def fake_request(app):
|
||||
return Request(b"/foobar", {}, "1.1", "GET", None, app)
|
||||
return Request(b"/foobar", {"accept": "*/*"}, "1.1", "GET", None, app)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -47,7 +47,13 @@ def test_should_return_html_valid_setting(
|
||||
try:
|
||||
raise exception("bad stuff")
|
||||
except Exception as e:
|
||||
response = exception_response(fake_request, e, True)
|
||||
response = exception_response(
|
||||
fake_request,
|
||||
e,
|
||||
True,
|
||||
base=HTMLRenderer,
|
||||
fallback=fake_request.app.config.FALLBACK_ERROR_FORMAT,
|
||||
)
|
||||
|
||||
assert isinstance(response, HTTPResponse)
|
||||
assert response.status == status
|
||||
@@ -74,13 +80,194 @@ 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"}
|
||||
"/error", headers={"content-type": "application/json", "accept": "*/*"}
|
||||
)
|
||||
assert response.status == 500
|
||||
assert response.content_type == "application/json"
|
||||
|
||||
_, response = app.test_client.get(
|
||||
"/error", headers={"content-type": "text/plain"}
|
||||
"/error", headers={"content-type": "foo/bar", "accept": "*/*"}
|
||||
)
|
||||
assert response.status == 500
|
||||
assert response.content_type == "text/html; charset=utf-8"
|
||||
|
||||
|
||||
def test_route_error_format_set_on_auto(app):
|
||||
@app.get("/text")
|
||||
def text_response(request):
|
||||
return text(request.route.ctx.error_format)
|
||||
|
||||
@app.get("/json")
|
||||
def json_response(request):
|
||||
return json({"format": request.route.ctx.error_format})
|
||||
|
||||
@app.get("/html")
|
||||
def html_response(request):
|
||||
return html(request.route.ctx.error_format)
|
||||
|
||||
_, response = app.test_client.get("/text")
|
||||
assert response.text == "text"
|
||||
|
||||
_, response = app.test_client.get("/json")
|
||||
assert response.json["format"] == "json"
|
||||
|
||||
_, response = app.test_client.get("/html")
|
||||
assert response.text == "html"
|
||||
|
||||
|
||||
def test_route_error_response_from_auto_route(app):
|
||||
@app.get("/text")
|
||||
def text_response(request):
|
||||
raise Exception("oops")
|
||||
return text("Never gonna see this")
|
||||
|
||||
@app.get("/json")
|
||||
def json_response(request):
|
||||
raise Exception("oops")
|
||||
return json({"message": "Never gonna see this"})
|
||||
|
||||
@app.get("/html")
|
||||
def html_response(request):
|
||||
raise Exception("oops")
|
||||
return html("<h1>Never gonna see this</h1>")
|
||||
|
||||
_, response = app.test_client.get("/text")
|
||||
assert response.content_type == "text/plain; charset=utf-8"
|
||||
|
||||
_, response = app.test_client.get("/json")
|
||||
assert response.content_type == "application/json"
|
||||
|
||||
_, response = app.test_client.get("/html")
|
||||
assert response.content_type == "text/html; charset=utf-8"
|
||||
|
||||
|
||||
def test_route_error_response_from_explicit_format(app):
|
||||
@app.get("/text", error_format="json")
|
||||
def text_response(request):
|
||||
raise Exception("oops")
|
||||
return text("Never gonna see this")
|
||||
|
||||
@app.get("/json", error_format="text")
|
||||
def json_response(request):
|
||||
raise Exception("oops")
|
||||
return json({"message": "Never gonna see this"})
|
||||
|
||||
_, response = app.test_client.get("/text")
|
||||
assert response.content_type == "application/json"
|
||||
|
||||
_, response = app.test_client.get("/json")
|
||||
assert response.content_type == "text/plain; charset=utf-8"
|
||||
|
||||
|
||||
def test_unknown_fallback_format(app):
|
||||
with pytest.raises(SanicException, match="Unknown format: bad"):
|
||||
app.config.FALLBACK_ERROR_FORMAT = "bad"
|
||||
|
||||
|
||||
def test_route_error_format_unknown(app):
|
||||
with pytest.raises(SanicException, match="Unknown format: bad"):
|
||||
|
||||
@app.get("/text", error_format="bad")
|
||||
def handler(request):
|
||||
...
|
||||
|
||||
|
||||
def test_fallback_with_content_type_mismatch_accept(app):
|
||||
app.config.FALLBACK_ERROR_FORMAT = "auto"
|
||||
|
||||
_, response = app.test_client.get(
|
||||
"/error",
|
||||
headers={"content-type": "application/json", "accept": "text/plain"},
|
||||
)
|
||||
assert response.status == 500
|
||||
assert response.content_type == "text/plain; charset=utf-8"
|
||||
|
||||
_, response = app.test_client.get(
|
||||
"/error",
|
||||
headers={"content-type": "text/plain", "accept": "foo/bar"},
|
||||
)
|
||||
assert response.status == 500
|
||||
assert response.content_type == "text/html; charset=utf-8"
|
||||
|
||||
app.router.reset()
|
||||
|
||||
@app.route("/alt1")
|
||||
@app.route("/alt2", error_format="text")
|
||||
@app.route("/alt3", error_format="html")
|
||||
def handler(_):
|
||||
raise Exception("problem here")
|
||||
# Yes, we know this return value is unreachable. This is on purpose.
|
||||
return json({})
|
||||
|
||||
app.router.finalize()
|
||||
|
||||
_, response = app.test_client.get(
|
||||
"/alt1",
|
||||
headers={"accept": "foo/bar"},
|
||||
)
|
||||
assert response.status == 500
|
||||
assert response.content_type == "text/html; charset=utf-8"
|
||||
_, response = app.test_client.get(
|
||||
"/alt1",
|
||||
headers={"accept": "foo/bar,*/*"},
|
||||
)
|
||||
assert response.status == 500
|
||||
assert response.content_type == "application/json"
|
||||
|
||||
_, response = app.test_client.get(
|
||||
"/alt2",
|
||||
headers={"accept": "foo/bar"},
|
||||
)
|
||||
assert response.status == 500
|
||||
assert response.content_type == "text/html; charset=utf-8"
|
||||
_, response = app.test_client.get(
|
||||
"/alt2",
|
||||
headers={"accept": "foo/bar,*/*"},
|
||||
)
|
||||
assert response.status == 500
|
||||
assert response.content_type == "text/plain; charset=utf-8"
|
||||
|
||||
_, response = app.test_client.get(
|
||||
"/alt3",
|
||||
headers={"accept": "foo/bar"},
|
||||
)
|
||||
assert response.status == 500
|
||||
assert response.content_type == "text/html; charset=utf-8"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"accept,content_type,expected",
|
||||
(
|
||||
(None, None, "text/plain; charset=utf-8"),
|
||||
("foo/bar", None, "text/html; charset=utf-8"),
|
||||
("application/json", None, "application/json"),
|
||||
("application/json,text/plain", None, "application/json"),
|
||||
("text/plain,application/json", None, "application/json"),
|
||||
("text/plain,foo/bar", None, "text/plain; charset=utf-8"),
|
||||
# Following test is valid after v22.3
|
||||
# ("text/plain,text/html", None, "text/plain; charset=utf-8"),
|
||||
("*/*", "foo/bar", "text/html; charset=utf-8"),
|
||||
("*/*", "application/json", "application/json"),
|
||||
),
|
||||
)
|
||||
def test_combinations_for_auto(fake_request, accept, content_type, expected):
|
||||
if accept:
|
||||
fake_request.headers["accept"] = accept
|
||||
else:
|
||||
del fake_request.headers["accept"]
|
||||
|
||||
if content_type:
|
||||
fake_request.headers["content-type"] = content_type
|
||||
|
||||
try:
|
||||
raise Exception("bad stuff")
|
||||
except Exception as e:
|
||||
response = exception_response(
|
||||
fake_request,
|
||||
e,
|
||||
True,
|
||||
base=HTMLRenderer,
|
||||
fallback="auto",
|
||||
)
|
||||
|
||||
assert response.content_type == expected
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from sanic import Sanic
|
||||
@@ -8,9 +10,6 @@ from sanic.handlers import ErrorHandler
|
||||
from sanic.response import stream, text
|
||||
|
||||
|
||||
exception_handler_app = Sanic("test_exception_handler")
|
||||
|
||||
|
||||
async def sample_streaming_fn(response):
|
||||
await response.write("foo,")
|
||||
await asyncio.sleep(0.001)
|
||||
@@ -21,107 +20,102 @@ class ErrorWithRequestCtx(ServerError):
|
||||
pass
|
||||
|
||||
|
||||
@exception_handler_app.route("/1")
|
||||
def handler_1(request):
|
||||
raise InvalidUsage("OK")
|
||||
@pytest.fixture
|
||||
def exception_handler_app():
|
||||
exception_handler_app = Sanic("test_exception_handler")
|
||||
|
||||
@exception_handler_app.route("/1", error_format="html")
|
||||
def handler_1(request):
|
||||
raise InvalidUsage("OK")
|
||||
|
||||
@exception_handler_app.route("/2", error_format="html")
|
||||
def handler_2(request):
|
||||
raise ServerError("OK")
|
||||
|
||||
@exception_handler_app.route("/3", error_format="html")
|
||||
def handler_3(request):
|
||||
raise NotFound("OK")
|
||||
|
||||
@exception_handler_app.route("/4", error_format="html")
|
||||
def handler_4(request):
|
||||
foo = bar # noqa -- F821
|
||||
return text(foo)
|
||||
|
||||
@exception_handler_app.route("/5", error_format="html")
|
||||
def handler_5(request):
|
||||
class CustomServerError(ServerError):
|
||||
pass
|
||||
|
||||
raise CustomServerError("Custom server error")
|
||||
|
||||
@exception_handler_app.route("/6/<arg:int>", error_format="html")
|
||||
def handler_6(request, arg):
|
||||
try:
|
||||
foo = 1 / arg
|
||||
except Exception as e:
|
||||
raise e from ValueError(f"{arg}")
|
||||
return text(foo)
|
||||
|
||||
@exception_handler_app.route("/7", error_format="html")
|
||||
def handler_7(request):
|
||||
raise Forbidden("go away!")
|
||||
|
||||
@exception_handler_app.route("/8", error_format="html")
|
||||
def handler_8(request):
|
||||
|
||||
raise ErrorWithRequestCtx("OK")
|
||||
|
||||
@exception_handler_app.exception(ErrorWithRequestCtx, NotFound)
|
||||
def handler_exception_with_ctx(request, exception):
|
||||
return text(request.ctx.middleware_ran)
|
||||
|
||||
@exception_handler_app.exception(ServerError)
|
||||
def handler_exception(request, exception):
|
||||
return text("OK")
|
||||
|
||||
@exception_handler_app.exception(Forbidden)
|
||||
async def async_handler_exception(request, exception):
|
||||
return stream(
|
||||
sample_streaming_fn,
|
||||
content_type="text/csv",
|
||||
)
|
||||
|
||||
@exception_handler_app.middleware
|
||||
async def some_request_middleware(request):
|
||||
request.ctx.middleware_ran = "Done."
|
||||
|
||||
return exception_handler_app
|
||||
|
||||
|
||||
@exception_handler_app.route("/2")
|
||||
def handler_2(request):
|
||||
raise ServerError("OK")
|
||||
|
||||
|
||||
@exception_handler_app.route("/3")
|
||||
def handler_3(request):
|
||||
raise NotFound("OK")
|
||||
|
||||
|
||||
@exception_handler_app.route("/4")
|
||||
def handler_4(request):
|
||||
foo = bar # noqa -- F821 undefined name 'bar' is done to throw exception
|
||||
return text(foo)
|
||||
|
||||
|
||||
@exception_handler_app.route("/5")
|
||||
def handler_5(request):
|
||||
class CustomServerError(ServerError):
|
||||
pass
|
||||
|
||||
raise CustomServerError("Custom server error")
|
||||
|
||||
|
||||
@exception_handler_app.route("/6/<arg:int>")
|
||||
def handler_6(request, arg):
|
||||
try:
|
||||
foo = 1 / arg
|
||||
except Exception as e:
|
||||
raise e from ValueError(f"{arg}")
|
||||
return text(foo)
|
||||
|
||||
|
||||
@exception_handler_app.route("/7")
|
||||
def handler_7(request):
|
||||
raise Forbidden("go away!")
|
||||
|
||||
|
||||
@exception_handler_app.route("/8")
|
||||
def handler_8(request):
|
||||
|
||||
raise ErrorWithRequestCtx("OK")
|
||||
|
||||
|
||||
@exception_handler_app.exception(ErrorWithRequestCtx, NotFound)
|
||||
def handler_exception_with_ctx(request, exception):
|
||||
return text(request.ctx.middleware_ran)
|
||||
|
||||
|
||||
@exception_handler_app.exception(ServerError)
|
||||
def handler_exception(request, exception):
|
||||
return text("OK")
|
||||
|
||||
|
||||
@exception_handler_app.exception(Forbidden)
|
||||
async def async_handler_exception(request, exception):
|
||||
return stream(
|
||||
sample_streaming_fn,
|
||||
content_type="text/csv",
|
||||
)
|
||||
|
||||
|
||||
@exception_handler_app.middleware
|
||||
async def some_request_middleware(request):
|
||||
request.ctx.middleware_ran = "Done."
|
||||
|
||||
|
||||
def test_invalid_usage_exception_handler():
|
||||
def test_invalid_usage_exception_handler(exception_handler_app):
|
||||
request, response = exception_handler_app.test_client.get("/1")
|
||||
assert response.status == 400
|
||||
|
||||
|
||||
def test_server_error_exception_handler():
|
||||
def test_server_error_exception_handler(exception_handler_app):
|
||||
request, response = exception_handler_app.test_client.get("/2")
|
||||
assert response.status == 200
|
||||
assert response.text == "OK"
|
||||
|
||||
|
||||
def test_not_found_exception_handler():
|
||||
def test_not_found_exception_handler(exception_handler_app):
|
||||
request, response = exception_handler_app.test_client.get("/3")
|
||||
assert response.status == 200
|
||||
|
||||
|
||||
def test_text_exception__handler():
|
||||
def test_text_exception__handler(exception_handler_app):
|
||||
request, response = exception_handler_app.test_client.get("/random")
|
||||
assert response.status == 200
|
||||
assert response.text == "Done."
|
||||
|
||||
|
||||
def test_async_exception_handler():
|
||||
def test_async_exception_handler(exception_handler_app):
|
||||
request, response = exception_handler_app.test_client.get("/7")
|
||||
assert response.status == 200
|
||||
assert response.text == "foo,bar"
|
||||
|
||||
|
||||
def test_html_traceback_output_in_debug_mode():
|
||||
def test_html_traceback_output_in_debug_mode(exception_handler_app):
|
||||
request, response = exception_handler_app.test_client.get("/4", debug=True)
|
||||
assert response.status == 500
|
||||
soup = BeautifulSoup(response.body, "html.parser")
|
||||
@@ -136,12 +130,12 @@ def test_html_traceback_output_in_debug_mode():
|
||||
) == summary_text
|
||||
|
||||
|
||||
def test_inherited_exception_handler():
|
||||
def test_inherited_exception_handler(exception_handler_app):
|
||||
request, response = exception_handler_app.test_client.get("/5")
|
||||
assert response.status == 200
|
||||
|
||||
|
||||
def test_chained_exception_handler():
|
||||
def test_chained_exception_handler(exception_handler_app):
|
||||
request, response = exception_handler_app.test_client.get(
|
||||
"/6/0", debug=True
|
||||
)
|
||||
@@ -153,7 +147,6 @@ def test_chained_exception_handler():
|
||||
assert "handler_6" in html
|
||||
assert "foo = 1 / arg" in html
|
||||
assert "ValueError" in html
|
||||
assert "The above exception was the direct cause" in html
|
||||
|
||||
summary_text = " ".join(soup.select(".summary")[0].text.split())
|
||||
assert (
|
||||
@@ -161,7 +154,7 @@ def test_chained_exception_handler():
|
||||
) == summary_text
|
||||
|
||||
|
||||
def test_exception_handler_lookup():
|
||||
def test_exception_handler_lookup(exception_handler_app):
|
||||
class CustomError(Exception):
|
||||
pass
|
||||
|
||||
@@ -184,7 +177,7 @@ def test_exception_handler_lookup():
|
||||
class ModuleNotFoundError(ImportError):
|
||||
pass
|
||||
|
||||
handler = ErrorHandler()
|
||||
handler = ErrorHandler("auto")
|
||||
handler.add(ImportError, import_error_handler)
|
||||
handler.add(CustomError, custom_error_handler)
|
||||
handler.add(ServerError, server_error_handler)
|
||||
@@ -209,7 +202,7 @@ def test_exception_handler_lookup():
|
||||
)
|
||||
|
||||
|
||||
def test_exception_handler_processed_request_middleware():
|
||||
def test_exception_handler_processed_request_middleware(exception_handler_app):
|
||||
request, response = exception_handler_app.test_client.get("/8")
|
||||
assert response.status == 200
|
||||
assert response.text == "Done."
|
||||
|
||||
@@ -5,6 +5,7 @@ import pytest
|
||||
from sanic import headers, text
|
||||
from sanic.exceptions import InvalidHeader, PayloadTooLarge
|
||||
from sanic.http import Http
|
||||
from sanic.request import Request
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -338,3 +339,31 @@ def test_value_in_accept(value):
|
||||
assert "foo/bar" in acceptable
|
||||
assert "foo/*" in acceptable
|
||||
assert "*/*" in acceptable
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ("foo/bar", "foo/*"))
|
||||
def test_value_not_in_accept(value):
|
||||
acceptable = headers.parse_accept(value)
|
||||
assert "no/match" not in acceptable
|
||||
assert "no/*" not in acceptable
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"header,expected",
|
||||
(
|
||||
(
|
||||
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", # noqa: E501
|
||||
[
|
||||
"text/html",
|
||||
"application/xhtml+xml",
|
||||
"image/avif",
|
||||
"image/webp",
|
||||
"application/xml;q=0.9",
|
||||
"*/*;q=0.8",
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
def test_browser_headers(header, expected):
|
||||
request = Request(b"/", {"accept": header}, "1.1", "GET", None, None)
|
||||
assert request.accept == expected
|
||||
|
||||
Reference in New Issue
Block a user