Move all errorpages work to another branch error-format-redux.

This commit is contained in:
L. Karkkainen 2023-01-29 03:11:27 +00:00
parent c0ca55530e
commit b8ae4285a4

View File

@ -21,7 +21,6 @@ from traceback import extract_tb
from sanic.exceptions import BadRequest, SanicException
from sanic.helpers import STATUS_CODES
from sanic.log import deprecation
from sanic.request import Request
from sanic.response import HTTPResponse, html, json, text
@ -404,12 +403,17 @@ RENDERERS_BY_CONTENT_TYPE = {
CONTENT_TYPE_BY_RENDERERS = {
v: k for k, v in RENDERERS_BY_CONTENT_TYPE.items()
}
# Handler source code is checked for which response types it returns
# If it returns (exactly) one of these, it will be used as render_format
RESPONSE_MAPPING = {
"empty": "html",
"json": "json",
"text": "text",
"raw": "text",
"html": "html",
"file": "html",
"file_stream": "text",
"stream": "text",
"redirect": "html",
"text/plain": "text",
"text/html": "html",
"application/json": "json",
@ -432,73 +436,98 @@ def exception_response(
"""
Render a response for the default FALLBACK exception handler.
"""
content_type = None
if not renderer:
renderer = _guess_renderer(request, fallback, base)
# Make sure we have something set
renderer = base
render_format = fallback
if request:
# If there is a request, try and get the format
# from the route
if request.route:
try:
if request.route.extra.error_format:
render_format = request.route.extra.error_format
except AttributeError:
...
content_type = request.headers.getone("content-type", "").split(
";"
)[0]
acceptable = request.accept
# If the format is auto still, make a guess
if render_format == "auto":
# First, if there is an Accept header, check if text/html
# is the first option
# According to MDN Web Docs, all major browsers use text/html
# as the primary value in Accept (with the exception of IE 8,
# and, well, if you are supporting IE 8, then you have bigger
# problems to concern yourself with than what default exception
# renderer is used)
# Source:
# https://developer.mozilla.org/en-US/docs/Web/HTTP/Content_negotiation/List_of_default_Accept_values
if acceptable and acceptable[0].match(
"text/html",
allow_type_wildcard=False,
allow_subtype_wildcard=False,
):
renderer = HTMLRenderer
# Second, if there is an Accept header, check if
# application/json is an option, or if the content-type
# is application/json
elif (
acceptable
and acceptable.match(
"application/json",
allow_type_wildcard=False,
allow_subtype_wildcard=False,
)
or content_type == "application/json"
):
renderer = JSONRenderer
# Third, if there is no Accept header, assume we want text.
# The likely use case here is a raw socket.
elif not acceptable:
renderer = TextRenderer
else:
# Fourth, look to see if there was a JSON body
# When in this situation, the request is probably coming
# from curl, an API client like Postman or Insomnia, or a
# package like requests or httpx
try:
# Give them the benefit of the doubt if they did:
# $ curl localhost:8000 -d '{"foo": "bar"}'
# And provide them with JSONRenderer
renderer = JSONRenderer if request.json else base
except BadRequest:
renderer = base
else:
renderer = RENDERERS_BY_CONFIG.get(render_format, renderer)
# Lastly, if there is an Accept header, make sure
# our choice is okay
if acceptable:
type_ = CONTENT_TYPE_BY_RENDERERS.get(renderer) # type: ignore
if type_ and type_ not in acceptable:
# If the renderer selected is not in the Accept header
# look through what is in the Accept header, and select
# the first option that matches. Otherwise, just drop back
# to the original default
for accept in acceptable:
mtype = f"{accept.type_}/{accept.subtype}"
maybe = RENDERERS_BY_CONTENT_TYPE.get(mtype)
if maybe:
renderer = maybe
break
else:
renderer = base
renderer = t.cast(t.Type[BaseRenderer], renderer)
return renderer(request, exception, debug).render()
def _acceptable(req, mediatype):
# Check if the given type/subtype is an acceptable response
# TODO: Consider defaulting req.accept to */*:q=0 when there is no
# accept header at all, to allow simply using match().
return not req.accept or req.accept.match(mediatype)
def _guess_renderer(req: Request, fallback: str, base: t.Type[BaseRenderer]) -> t.Type[BaseRenderer]:
# Renderer selection order:
# 1. Accept header (ignoring */* or types with q=0)
# 2. Route error_format
# 3. FALLBACK if set by app
# 4. Content-type for JSON
#
# If none of the above match or are in conflict with accept header,
# then the base renderer is returned.
#
# Arguments:
# - fallback is auto/json/html/text (app.config.FALLBACK_ERROR_FORMAT)
# - base is always TextRenderer unless set via
# Sanic(error_handler=ErrorRenderer(SomeOtherRenderer))
# Use the Accept header preference to choose one of the renderers
mediatype, accept_q = req.accept.choose(*RENDERERS_BY_CONTENT_TYPE)
if accept_q:
return RENDERERS_BY_CONTENT_TYPE[mediatype]
# No clear preference, so employ fuzzy logic to find render_format
render_format = fallback
# Check the route for what the handler returns (magic)
# Note: this is done despite having a non-auto fallback
if req.route:
try:
if req.route.extra.error_format:
render_format = req.route.extra.error_format
except AttributeError:
pass
# If still not known, check for JSON content-type
if render_format == "auto":
mediatype = req.headers.getone("content-type", "").split(";", 1)[0]
if mediatype == "application/json":
render_format = "json"
# Check for JSON body content (DEPRECATED, backwards compatibility)
if render_format == "auto" and _acceptable(req, "application/json"):
try:
if req.json:
render_format = "json"
deprecation(
"Response type was determined by the JSON content of "
"the request. This behavior is deprecated and will be "
"removed in v24.3. Please specify the format either by\n"
" FALLBACK_ERROR_FORMAT = 'json', or by adding header\n"
" accept: application/json to your requests.",
24.3,
)
except Exception:
pass
# Use render_format if found and acceptable, otherwise fallback to base
renderer = RENDERERS_BY_CONFIG.get(render_format, base)
mediatype = CONTENT_TYPE_BY_RENDERERS[renderer] # type: ignore
return renderer if _acceptable(req, mediatype) else base