Files
responder/tests/test_negotiation_fixes.py
kennethreitz d3288db1fe Improve content negotiation: Accept ranges/q-values, msgpack detect, request charset
Three confirmed content-negotiation findings from the audit:

- accepts() substring-matched the raw Accept header, ignoring media ranges
  and q-values (e.g. Accept: */* did not accept "application/json"). Parse
  Accept into media ranges with q-values, honor wildcards (*/*, type/*), and
  treat q=0 as not acceptable. An absent Accept header accepts anything. Bare
  subtype tokens (the internal "json"/"yaml" usage) still match.

- media() auto-detection never selected msgpack, so an
  application/x-msgpack body fell through to JSON and 400'd. Add a msgpack
  branch to the detection ladder.

- Request body decoding ignored the standard Content-Type charset= parameter
  and guessed with chardet instead. Honor charset= in declared_encoding
  (chardet stays as the no-charset fallback).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 15:18:36 -04:00

93 lines
2.7 KiB
Python

"""Content-negotiation & decoding fixes:
- accepts() honors media ranges (*/*, type/*) and q-values
- media() auto-detects msgpack
- request body decoding honors the Content-Type charset= parameter
"""
import msgpack
from starlette.testclient import TestClient
import responder
def _client(api):
return TestClient(api, base_url="http://;")
def _accepts_api():
api = responder.API(allowed_hosts=[";"], session_https_only=False)
@api.route("/a")
def view(req, resp):
resp.media = {
"full": req.accepts("application/json"),
"token": req.accepts("json"),
}
return api
def test_accepts_wildcard_range():
r = _client(_accepts_api()).get("/a", headers={"Accept": "*/*"})
assert r.json() == {"full": True, "token": True}
def test_accepts_type_wildcard():
r = _client(_accepts_api()).get("/a", headers={"Accept": "application/*"})
assert r.json()["full"] is True
def test_accepts_exact_and_qvalue_zero():
api = _accepts_api()
assert _client(api).get("/a", headers={"Accept": "application/json"}).json()["full"]
# q=0 means "not acceptable".
r = _client(api).get(
"/a", headers={"Accept": "application/json;q=0, text/html"}
)
assert r.json()["full"] is False
def test_accepts_absent_header_accepts_anything():
# Starlette TestClient sends a default Accept of */* when omitted, so send
# an explicit empty header to exercise the "absent" branch.
r = _client(_accepts_api()).get("/a", headers={"Accept": ""})
assert r.json() == {"full": True, "token": True}
def test_accepts_non_matching_type():
r = _client(_accepts_api()).get("/a", headers={"Accept": "text/html"})
assert r.json() == {"full": False, "token": False}
def test_media_autodetects_msgpack():
api = responder.API(allowed_hosts=[";"], session_https_only=False)
@api.route("/m", methods=["POST"])
async def view(req, resp):
resp.media = {"got": await req.media()}
body = msgpack.packb({"x": 1, "y": [1, 2, 3]})
r = _client(api).post(
"/m", content=body, headers={"Content-Type": "application/x-msgpack"}
)
assert r.status_code == 200
assert r.json() == {"got": {"x": 1, "y": [1, 2, 3]}}
def test_request_body_honors_declared_charset():
api = responder.API(allowed_hosts=[";"], session_https_only=False)
@api.route("/t", methods=["POST"])
async def view(req, resp):
resp.media = {"text": await req.text}
# "café" encoded as latin-1; a UTF-8 or chardet guess would mangle it.
r = _client(api).post(
"/t",
content="café".encode("latin-1"),
headers={"Content-Type": "text/plain; charset=iso-8859-1"},
)
assert r.status_code == 200
assert r.json() == {"text": "café"}