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

Error page rendering format selection #2668

Merged
merged 73 commits into from
Feb 26, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
73 commits
Select commit Hold shift + click to select a range
43c9a0a
Additional accept functionality
ahopkins Jan 24, 2023
8eeb1c2
Unfinished hacks, moving to another machine.
Tronic Jan 29, 2023
e35286e
Rethinking of renderer selection logic, cleanup.
Tronic Jan 29, 2023
7909f67
Handle empty/missing accept header more directly
Tronic Jan 29, 2023
dfe2148
Remove dubious or unnecessary handler types of response mapping.
Tronic Jan 29, 2023
3ef9956
Refactor acceptable check to a helper function.
Tronic Jan 29, 2023
52ecbb9
Note that base renderer can be changed.
Tronic Jan 29, 2023
c0ca555
Add back JSON detection by request body, but to be deprecated.
Tronic Jan 29, 2023
b8ae428
Move all errorpages work to another branch error-format-redux.
Tronic Jan 29, 2023
96f1cf9
Separate response mapping changes to PR #2659.
Tronic Jan 29, 2023
f44b5e3
Addn. to previous commit.
Tronic Jan 29, 2023
43afaa4
Accept header choose() function removed and replaced by a more versat…
Tronic Jan 30, 2023
4ff128e
Rewritten error format selection once more, factored Renderers out to…
Tronic Jan 30, 2023
ec25581
Accept header choose() function removed and replaced by a more versat…
Tronic Jan 30, 2023
8da10a9
Compatibility with older version.
Tronic Jan 30, 2023
2e22319
Updated/removed tests due toe accept/mediatype complete API and seman…
Tronic Jan 30, 2023
7abc430
Merge branch 'accept-enhance' into error-format-redux
Tronic Jan 30, 2023
902d01f
Optimize for easier documentation. FALLBACK put before JSON heuristics.
Tronic Jan 30, 2023
95f9e68
Make request.accept default to */* if the header is missing (as per R…
Tronic Jan 30, 2023
93f4c81
Merge branch 'main' into error-format-redux
Tronic Jan 30, 2023
b90569c
Remove unused renderer-type mappings.
Tronic Jan 30, 2023
5d816d8
Backwards compatibility with old versions, using JSON when nothing el…
Tronic Jan 31, 2023
e8ad79f
Fixed tests for both old and new errorpages.
Tronic Jan 31, 2023
b51e462
lint
Tronic Jan 31, 2023
a30dec5
lint accept
Tronic Jan 31, 2023
54a9822
lint accept
Tronic Jan 31, 2023
3a66b62
Handling of accept header missing in parse_accept.
Tronic Jan 31, 2023
f60cb91
Remove unused decorator; taking that MIMEs are plain str and removing…
Tronic Jan 31, 2023
5f294e8
Additional accept matching tests.
Tronic Jan 31, 2023
988b3ab
Catch specific exception
Tronic Jan 31, 2023
789bcd7
Mention error_format in deprecation message.
Tronic Jan 31, 2023
61760b0
Cleanup
Tronic Jan 31, 2023
dc8d862
Tests
Tronic Feb 1, 2023
170a8c5
Merge branch 'main' into error-format-redux
Tronic Feb 5, 2023
eaacfe5
Cherry-pick today's commits from accept-enhance.
Tronic Feb 5, 2023
7c9da63
Ruff linter error
Tronic Feb 5, 2023
4d87856
Property rename that was missed in earlier rename. Headers tests all …
Tronic Feb 5, 2023
fa605fa
Fix failing test, add more functionality and more checks.
Tronic Feb 7, 2023
a929a7e
More descriptive name Match.header for the MediaType matched.
Tronic Feb 7, 2023
87942c1
Check for possible mistake, add tests.
Tronic Feb 7, 2023
b48bd93
Repr format is more suitable for this error message.
Tronic Feb 7, 2023
522dfde
Make Matched object not a str to avoid it having comparisons. Remove …
Tronic Feb 13, 2023
06c6012
Remove support for disabling wildcards on MediaType matching.
Tronic Feb 13, 2023
0448990
Accept.match can ignore wildcard entries from Accept header.
Tronic Feb 13, 2023
5f88c7e
100% test coverage.
Tronic Feb 13, 2023
230b900
Addn. test for not matching wildcards
Tronic Feb 13, 2023
393be66
Remove handling of AttributeError that was never hit by tests.
Tronic Feb 13, 2023
0c8dc6b
Test JSON detection by request content-type
Tronic Feb 13, 2023
60a7aab
Remove a comment.
Tronic Feb 13, 2023
dbbbc52
Need to use m.mime where str is expected because the type no longer d…
Tronic Feb 13, 2023
0295a9c
Correct the JSON content-type test just added.
Tronic Feb 13, 2023
dc5b76d
Merge branch 'main' into error-format-redux
Tronic Feb 14, 2023
18a84dd
Blend new pattern with backwards compat
ahopkins Feb 16, 2023
284d82e
Blend new pattern with backwards compat
ahopkins Feb 16, 2023
b255145
Update errorpage to not use in
ahopkins Feb 16, 2023
7d84be3
Add check for typing
ahopkins Feb 16, 2023
7237fdb
Remove commented out code
ahopkins Feb 16, 2023
6eeee63
String only media type parts
ahopkins Feb 19, 2023
276fab8
Typing fixes
ahopkins Feb 19, 2023
843b3fe
Rename Accept to Matched
ahopkins Feb 19, 2023
5418bd0
Add back negative
ahopkins Feb 19, 2023
338e2b5
Cleanup tests
ahopkins Feb 19, 2023
9f77909
Fix error page test
ahopkins Feb 19, 2023
2ce3bd6
merge conflict
ahopkins Feb 21, 2023
255cb91
Resolve merge conflicts from main into error-format-redux (#2691)
ahopkins Feb 21, 2023
e6760d9
Don't parse content-type, str matching is faster.
Tronic Feb 21, 2023
42f4a83
Merge branch 'main' into error-format-redux
ahopkins Feb 21, 2023
42ef591
Merge conflicts
ahopkins Feb 21, 2023
dc6db87
Fix bad merge
ahopkins Feb 21, 2023
b01303f
Add any formats only if fallback is auto
Tronic Feb 26, 2023
7212baa
Tests
Tronic Feb 26, 2023
b188893
JSON autodetection by request content-type or body only if fallback i…
Tronic Feb 26, 2023
87edd4d
Remove unused import
Tronic Feb 26, 2023
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
165 changes: 69 additions & 96 deletions sanic/errorpages.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@

from sanic.exceptions import BadRequest, SanicException
from sanic.helpers import STATUS_CODES
from sanic.log import deprecation, logger
from sanic.response import html, json, text


Expand All @@ -42,6 +43,7 @@
"cannot complete your request."
)
FALLBACK_STATUS = 500
JSON = "application/json"


class BaseRenderer:
Expand Down Expand Up @@ -390,21 +392,18 @@ def escape(text):
return f"{text}".replace("&", "&amp;").replace("<", "&lt;")


RENDERERS_BY_CONFIG = {
"html": HTMLRenderer,
"json": JSONRenderer,
"text": TextRenderer,
MIME_BY_CONFIG = {
"text": "text/plain",
"json": "application/json",
"html": "text/html",
}

CONFIG_BY_MIME = {v: k for k, v in MIME_BY_CONFIG.items()}
RENDERERS_BY_CONTENT_TYPE = {
"text/plain": TextRenderer,
"application/json": JSONRenderer,
"multipart/form-data": HTMLRenderer,
"text/html": HTMLRenderer,
}
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 with the
# route error_format="auto" (default) to determine which format to use.
Expand All @@ -420,7 +419,7 @@ def escape(text):


def check_error_format(format):
if format not in RENDERERS_BY_CONFIG and format != "auto":
if format not in MIME_BY_CONFIG and format != "auto":
raise SanicException(f"Unknown format: {format}")


Expand All @@ -435,94 +434,68 @@ def exception_response(
"""
Render a response for the default FALLBACK exception handler.
"""
content_type = None

if not renderer:
# 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.match(
"text/html", accept_wildcards=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", accept_wildcards=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 not acceptable.match(type_):
# 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
mt = guess_mime(request, fallback)
renderer = RENDERERS_BY_CONTENT_TYPE.get(mt, base)

renderer = t.cast(t.Type[BaseRenderer], renderer)
return renderer(request, exception, debug).render()


def guess_mime(req: Request, fallback: str) -> str:
# Attempt to find a suitable MIME format for the response.
# Insertion-ordered map of formats["html"] = "source of that suggestion"
formats = {}
name = ""
# Route error_format (by magic from handler code if auto, the default)
if req.route:
name = req.route.name
f = req.route.extra.error_format
if f in MIME_BY_CONFIG:
formats[f] = name

if not formats and fallback in MIME_BY_CONFIG:
formats[fallback] = "FALLBACK_ERROR_FORMAT"

# If still not known, check for the request for clues of JSON
if not formats and fallback == "auto" and req.accept.match(JSON):
if JSON in req.accept: # Literally, not wildcard
formats["json"] = "request.accept"
elif JSON in req.headers.getone("content-type", ""):
formats["json"] = "content-type"
# DEPRECATION: Remove this block in 24.3
else:
c = None
try:
c = req.json
except BadRequest:
pass
if c:
formats["json"] = "request.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"
f' error_format="json" on route {name}, by\n'
' FALLBACK_ERROR_FORMAT = "json", or by adding header\n'
" accept: application/json to your requests.",
24.3,
)

# Any other supported formats
if fallback == "auto":
for k in MIME_BY_CONFIG:
if k not in formats:
formats[k] = "any"

mimes = [MIME_BY_CONFIG[k] for k in formats]
m = req.accept.match(*mimes)
if m:
format = CONFIG_BY_MIME[m.mime]
source = formats[format]
logger.debug(
f"The client accepts {m.header}, using '{format}' from {source}"
)
else:
logger.debug(f"No format found, the client accepts {req.accept!r}")
return m.mime
1 change: 0 additions & 1 deletion sanic/headers.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,6 @@ def __eq__(self, other: Any) -> bool:

def _compare(self, other) -> Tuple[bool, Matched]:
if isinstance(other, str):
# return self.mime == other, Accept.parse(other)
parsed = Matched.parse(other)
if self.mime == other:
return True, parsed
Expand Down
9 changes: 6 additions & 3 deletions sanic/request.py
Original file line number Diff line number Diff line change
Expand Up @@ -500,13 +500,16 @@ def load_json(self, loads=None):

@property
def accept(self) -> AcceptList:
"""
"""Accepted response content types.

A convenience handler for easier RFC-compliant matching of MIME types,
parsed as a list that can match wildcards and includes */* by default.

:return: The ``Accept`` header parsed
:rtype: AcceptList
"""
if self.parsed_accept is None:
accept_header = self.headers.getone("accept", "")
self.parsed_accept = parse_accept(accept_header)
self.parsed_accept = parse_accept(self.headers.get("accept"))
return self.parsed_accept

@property
Expand Down
Loading