mirror of
https://github.com/kennethreitz/responder.git
synced 2026-07-21 18:39:29 +00:00
428181a096
Flagship: JWTAuth (HS256/RS256/ES256, exp/aud/iss, JWKS with off-loop fetch, scope claims -> requires()) and OAuth2Auth flow descriptions that light up Swagger UI's Authorize button, behind a new responder[jwt] extra. Also additive: responder.Problem (raisable RFC 9457) + RFC 9457 abort(); @api.middleware function-style HTTP middleware; Pydantic bodies and Query/Header/Cookie/Path markers on class-based and WebSocket handlers; Form-model binding; write-side preconditions (If-Match/If-Unmodified-Since -> 412); q-ranked content negotiation; get_list for repeated headers; status_code= route option; OpenAPI document caching; rate-limiter key=/ fail-open/X-RateLimit-Reset; configurable metrics buckets + in-flight gauge; RFC 8288 pagination Link headers; Redis session serializer; unified request-ID policy; CLI --host/--port/--server/--reload; optional orjson encode backend; AsyncTestClient + SSE test helpers; fakeredis backend coverage. Five DeprecationWarnings start the 9.0 clock. Every feature was implemented test-first, then the combined diff went through a 64-agent adversarial review that caught 19 issues including two criticals (an auth scope-claim regression that flipped authorization decisions, and a sync-middleware threadpool deadlock) -- all fixed with regression tests before release. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
292 lines
8.1 KiB
Python
292 lines
8.1 KiB
Python
"""Tests for the raisable RFC 9457 ``responder.Problem`` exception."""
|
|
|
|
import datetime as dt
|
|
|
|
import pytest
|
|
from starlette.exceptions import HTTPException
|
|
from starlette.testclient import TestClient
|
|
|
|
import responder
|
|
from responder import Problem, abort
|
|
|
|
|
|
def _api(**kwargs):
|
|
return responder.API(
|
|
title="T",
|
|
version="1",
|
|
secret_key="x" * 32,
|
|
allowed_hosts=[";"],
|
|
session_https_only=False,
|
|
**kwargs,
|
|
)
|
|
|
|
|
|
def _client(api):
|
|
return TestClient(api, base_url="http://;", raise_server_exceptions=False)
|
|
|
|
|
|
def test_problem_is_an_http_exception():
|
|
exc = Problem(409)
|
|
assert isinstance(exc, HTTPException)
|
|
assert exc.status_code == 409
|
|
# Starlette fills detail with the status phrase.
|
|
assert exc.detail == "Conflict"
|
|
|
|
|
|
def test_problem_renders_full_rfc9457_payload():
|
|
api = _api()
|
|
|
|
@api.route("/quota")
|
|
def quota(req, resp):
|
|
raise Problem(
|
|
409,
|
|
"You have used all 100 requests for today.",
|
|
title="Quota Exceeded",
|
|
type="https://api.example.com/errors/quota-exceeded",
|
|
instance="/account/12345/quota",
|
|
balance=0,
|
|
)
|
|
|
|
r = _client(api).get("/quota")
|
|
assert r.status_code == 409
|
|
assert r.headers["content-type"].startswith("application/problem+json")
|
|
body = r.json()
|
|
assert body["type"] == "https://api.example.com/errors/quota-exceeded"
|
|
assert body["title"] == "Quota Exceeded"
|
|
assert body["status"] == 409
|
|
assert body["detail"] == "You have used all 100 requests for today."
|
|
assert body["instance"] == "/account/12345/quota"
|
|
assert body["balance"] == 0
|
|
|
|
|
|
def test_problem_defaults_title_and_instance():
|
|
api = _api()
|
|
|
|
@api.route("/things/{id}")
|
|
def thing(req, resp, *, id):
|
|
raise Problem(404, "No such thing.")
|
|
|
|
body = _client(api).get("/things/42").json()
|
|
assert body["type"] == "about:blank"
|
|
assert body["title"] == "Not Found"
|
|
assert body["detail"] == "No such thing."
|
|
# instance defaults to the request path per the RFC 9457 recommendation.
|
|
assert body["instance"] == "/things/42"
|
|
|
|
|
|
def test_plain_http_exception_payload_unchanged():
|
|
# No instance member sneaks into non-Problem error payloads.
|
|
api = _api()
|
|
|
|
@api.route("/boom")
|
|
def boom(req, resp):
|
|
abort(404, detail="gone")
|
|
|
|
body = _client(api).get("/boom").json()
|
|
assert body["title"] == "Not Found"
|
|
assert "instance" not in body
|
|
assert body["type"] == "about:blank"
|
|
|
|
|
|
def test_problem_headers_are_sent():
|
|
api = _api()
|
|
|
|
@api.route("/limited")
|
|
def limited(req, resp):
|
|
raise Problem(429, "Slow down.", headers={"Retry-After": "30"})
|
|
|
|
r = _client(api).get("/limited")
|
|
assert r.status_code == 429
|
|
assert r.headers["retry-after"] == "30"
|
|
|
|
|
|
def test_problem_errors_list():
|
|
api = _api()
|
|
|
|
@api.route("/validate")
|
|
def validate(req, resp):
|
|
raise Problem(
|
|
422,
|
|
"Validation failed.",
|
|
errors=[{"loc": ["body", "name"], "msg": "field required"}],
|
|
)
|
|
|
|
body = _client(api).get("/validate").json()
|
|
assert body["errors"] == [{"loc": ["body", "name"], "msg": "field required"}]
|
|
|
|
|
|
def test_problem_handler_enrichment_applies_and_explicit_fields_win():
|
|
def enrich(payload):
|
|
payload["support"] = "help@example.com"
|
|
payload["type"] = "https://example.com/handler-type"
|
|
return payload
|
|
|
|
api = _api(problem_handler=enrich)
|
|
|
|
@api.route("/conflict")
|
|
def conflict(req, resp):
|
|
raise Problem(409, type="https://example.com/explicit-type")
|
|
|
|
body = _client(api).get("/conflict").json()
|
|
# Enrichment ran...
|
|
assert body["support"] == "help@example.com"
|
|
# ...but the Problem's explicit members layer on top.
|
|
assert body["type"] == "https://example.com/explicit-type"
|
|
|
|
|
|
def test_problem_handler_can_set_instance_when_unset():
|
|
def enrich(payload):
|
|
payload["instance"] = "/from-handler"
|
|
return payload
|
|
|
|
api = _api(problem_handler=enrich)
|
|
|
|
@api.route("/thing")
|
|
def thing(req, resp):
|
|
raise Problem(410)
|
|
|
|
body = _client(api).get("/thing").json()
|
|
# A handler-provided instance beats the request-path default.
|
|
assert body["instance"] == "/from-handler"
|
|
|
|
|
|
def test_problem_extensions_use_json_encoder():
|
|
api = _api()
|
|
|
|
@api.route("/dated")
|
|
def dated(req, resp):
|
|
raise Problem(410, "Expired.", expired_at=dt.datetime(2026, 1, 1, 12, 0))
|
|
|
|
body = _client(api).get("/dated").json()
|
|
assert body["expired_at"] == "2026-01-01T12:00:00"
|
|
|
|
|
|
def test_problem_extensions_use_custom_encoder():
|
|
class Money:
|
|
def __init__(self, amount):
|
|
self.amount = amount
|
|
|
|
def encode(obj):
|
|
if isinstance(obj, Money):
|
|
return f"${obj.amount}"
|
|
raise TypeError(f"cannot encode {type(obj).__name__}")
|
|
|
|
api = _api(encoder=encode)
|
|
|
|
@api.route("/priced")
|
|
def priced(req, resp):
|
|
raise Problem(402, "Payment required.", price=Money(5))
|
|
|
|
body = _client(api).get("/priced").json()
|
|
assert body["price"] == "$5"
|
|
|
|
|
|
def test_problem_from_dependency():
|
|
api = _api()
|
|
|
|
def guard():
|
|
raise Problem(403, "Nope.", type="https://example.com/forbidden")
|
|
|
|
@api.route("/guarded", dependencies=[responder.Depends(guard)])
|
|
def guarded(req, resp):
|
|
resp.media = {"ok": True}
|
|
|
|
r = _client(api).get("/guarded")
|
|
assert r.status_code == 403
|
|
assert r.json()["type"] == "https://example.com/forbidden"
|
|
|
|
|
|
def test_problem_from_before_request_hook():
|
|
api = _api()
|
|
|
|
@api.route(before_request=True)
|
|
def hook(req, resp):
|
|
if req.url.path == "/blocked":
|
|
raise Problem(451, "Unavailable for legal reasons.")
|
|
|
|
@api.route("/blocked")
|
|
def blocked(req, resp):
|
|
resp.media = {"ok": True}
|
|
|
|
r = _client(api).get("/blocked")
|
|
assert r.status_code == 451
|
|
assert r.json()["status"] == 451
|
|
|
|
|
|
def test_problem_from_async_view():
|
|
api = _api()
|
|
|
|
@api.route("/async")
|
|
async def view(req, resp):
|
|
raise Problem(409, "Async conflict.", attempt=3)
|
|
|
|
body = _client(api).get("/async").json()
|
|
assert body["detail"] == "Async conflict."
|
|
assert body["attempt"] == 3
|
|
|
|
|
|
def test_problem_legacy_mode_uses_legacy_contract():
|
|
api = _api(problem_details=False)
|
|
|
|
@api.route("/old")
|
|
def old(req, resp):
|
|
raise Problem(409, "Old-style conflict.", balance=0)
|
|
|
|
client = _client(api)
|
|
r = client.get("/old", headers={"Accept": "application/json"})
|
|
assert r.status_code == 409
|
|
assert r.json() == {"error": "Old-style conflict."}
|
|
r = client.get("/old", headers={"Accept": "text/plain"})
|
|
assert r.status_code == 409
|
|
assert r.text == "Old-style conflict."
|
|
|
|
|
|
def test_abort_plain_still_raises_http_exception():
|
|
with pytest.raises(HTTPException) as excinfo:
|
|
abort(403, detail="Forbidden")
|
|
assert not isinstance(excinfo.value, Problem)
|
|
assert excinfo.value.status_code == 403
|
|
assert excinfo.value.detail == "Forbidden"
|
|
|
|
|
|
def test_abort_with_rich_members_raises_problem():
|
|
with pytest.raises(Problem) as excinfo:
|
|
abort(
|
|
409,
|
|
detail="Quota exhausted",
|
|
type="https://api.example.com/errors/quota-exceeded",
|
|
balance=0,
|
|
)
|
|
exc = excinfo.value
|
|
assert exc.type == "https://api.example.com/errors/quota-exceeded"
|
|
assert exc.extensions == {"balance": 0}
|
|
|
|
|
|
def test_abort_rich_members_render_end_to_end():
|
|
api = _api()
|
|
|
|
@api.route("/quota")
|
|
def quota(req, resp):
|
|
abort(
|
|
409,
|
|
detail="Quota exhausted",
|
|
title="Quota Exceeded",
|
|
type="https://api.example.com/errors/quota-exceeded",
|
|
balance=0,
|
|
)
|
|
|
|
r = _client(api).get("/quota")
|
|
assert r.status_code == 409
|
|
assert r.headers["content-type"].startswith("application/problem+json")
|
|
body = r.json()
|
|
assert body["type"] == "https://api.example.com/errors/quota-exceeded"
|
|
assert body["title"] == "Quota Exceeded"
|
|
assert body["detail"] == "Quota exhausted"
|
|
assert body["balance"] == 0
|
|
assert body["instance"] == "/quota"
|
|
|
|
|
|
def test_problem_exported_from_package_root():
|
|
assert responder.Problem is Problem
|
|
assert "Problem" in responder.__all__
|