Rethinking of renderer selection logic, cleanup.

This commit is contained in:
L. Karkkainen 2023-01-29 01:43:40 +00:00
parent 8eeb1c20dc
commit e35286e332
2 changed files with 36 additions and 44 deletions

View File

@ -444,53 +444,45 @@ def exception_response(
return renderer(request, exception, debug).render() return renderer(request, exception, debug).render()
def _guess_renderer(request: Request, fallback: str, base: t.Type[BaseRenderer]) -> t.Type[BaseRenderer]: def _guess_renderer(req: Request, fallback: str, base: t.Type[BaseRenderer]) -> t.Type[BaseRenderer]:
# base/fallback is app.config.FALLBACK_ERROR_FORMAT # Renderer selection order:
render_format = fallback # 1. Accept header (ignoring */* or types with q=0)
if not request: # 2. Route error_format
return base # 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
# Try the format from the route via RESPONSE_MAPPING # Use the Accept header preference to choose one of the renderers
if request.route: 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: try:
if request.route.extra.error_format: if req.route.extra.error_format:
render_format = request.route.extra.error_format render_format = req.route.extra.error_format
except AttributeError: except AttributeError:
pass pass
# Do we need to look at the request itself? # If still not known, check for JSON content-type
if render_format == "auto": if render_format == "auto":
# Use the Accept header to choose one mediatype = req.headers.getone("content-type", "").split(";", 1)[0]
mediatype, accept_q = request.accept.choose(*RENDERERS_BY_CONTENT_TYPE) if mediatype == "application/json":
if accept_q: render_format = "json"
return RENDERERS_BY_CONTENT_TYPE[mediatype]
# Otherwise, try JSON content type or request body
if not accept_q and "*/*" in request.accept and _check_json_content(request):
return JSONRenderer
return base
# Use the format from the route if it doesn't contradict the Accept header # Use render_format if found and acceptable, otherwise fallback to base
renderer = RENDERERS_BY_CONFIG.get(render_format, base) renderer = RENDERERS_BY_CONFIG.get(render_format, base)
type_ = CONTENT_TYPE_BY_RENDERERS[renderer] # type: ignore type_ = CONTENT_TYPE_BY_RENDERERS[renderer] # type: ignore
acceptable = not request.accept or request.accept.match(type_) acceptable = not req.accept or req.accept.match(type_)
return renderer if acceptable else base return renderer if acceptable else base
def _check_json_content(request: Request) -> bool:
content_type = request.headers.getone("content-type", "").split(";")[0]
if content_type == "application/json":
return True
# 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
if request.json: return True
except BadRequest:
pass
return False

View File

@ -114,7 +114,7 @@ class MediaType:
class AcceptList(list): class AcceptList(list):
"""A list of media types, as used in the Accept header. """A list of media types, as used in the Accept header.
The Accept header entries are listed in order of preference, starting The Accept header entries are listed in order of preference, starting
with the most preferred. This class is a list of `MediaType` objects, with the most preferred. This class is a list of `MediaType` objects,
that encapsulate also the q value or any other parameters. that encapsulate also the q value or any other parameters.
@ -149,7 +149,7 @@ class AcceptList(list):
def choose(self, *media_types: List[str], omit_wildcard=True) -> str: def choose(self, *media_types: List[str], omit_wildcard=True) -> str:
"""Choose a most suitable media type based on the Accept header. """Choose a most suitable media type based on the Accept header.
This is the recommended way to choose a response format based on the This is the recommended way to choose a response format based on the
Accept header. The q values and the order of the Accept header are Accept header. The q values and the order of the Accept header are
respected, and if due to wildcards multiple arguments match the same respected, and if due to wildcards multiple arguments match the same
@ -179,8 +179,8 @@ def parse_accept(accept: str) -> AcceptList:
https://datatracker.ietf.org/doc/html/rfc7231#section-5.3.2 https://datatracker.ietf.org/doc/html/rfc7231#section-5.3.2
""" """
try: try:
accept_list = [MediaType._parse(mtype) for mtype in accept.split(",")] a = [MediaType._parse(mtype) for mtype in accept.split(",") if mtype]
return AcceptList(sorted(accept_list, key=lambda mtype: -mtype.q)) return AcceptList(sorted(a, key=lambda mtype: -mtype.q))
except ValueError: except ValueError:
raise InvalidHeader(f"Invalid header value in Accept: {accept}") raise InvalidHeader(f"Invalid header value in Accept: {accept}")