Files
responder/tests/test_openapi_810.py
kennethreitz 428181a096 Add JWT/OAuth2 auth, Problem exception, function middleware, and a broad feature sweep for 8.1.0
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>
2026-07-03 07:08:42 -04:00

529 lines
15 KiB
Python

"""8.1.0 OpenAPI feature tests.
Covers: the ``status_code=`` route option (63) — the default success status is
pre-seeded on the response and documented in the OpenAPI operation instead of
the hardcoded ``200`` — and caching of the generated OpenAPI document with
invalidation when routes, schemas, or security schemes change (67).
"""
import enum
import pytest
import yaml
from openapi_spec_validator import validate
from pydantic import BaseModel, ConfigDict
import responder
from responder import File, Form, Router, UploadFile
def _api(**kwargs):
kwargs.setdefault("openapi", "3.1.0")
return responder.API(
title="T",
version="1",
allowed_hosts=[";"],
session_https_only=False,
**kwargs,
)
def _spec_of(api):
return yaml.safe_load(api.openapi.openapi)
class ItemIn(BaseModel):
name: str
class ItemOut(BaseModel):
id: int
name: str
# ---------------------------------------------------------------------------
# Item 63: status_code= pre-seeds the response's success status.
# ---------------------------------------------------------------------------
def test_status_code_seeds_default_success_status():
api = _api()
@api.route("/items", methods=["POST"], status_code=201)
async def create_item(req, resp):
resp.media = {"id": 1}
r = api.requests.post("http://;/items")
assert r.status_code == 201
assert r.json() == {"id": 1}
def test_status_code_explicit_assignment_in_view_wins():
api = _api()
@api.route("/items", methods=["POST"], status_code=201)
async def create_item(req, resp):
resp.status_code = 202
resp.media = {"queued": True}
r = api.requests.post("http://;/items")
assert r.status_code == 202
def test_status_code_default_is_still_200_without_option():
api = _api()
@api.route("/plain")
def plain(req, resp):
resp.media = {"ok": True}
r = api.requests.get("http://;/plain")
assert r.status_code == 200
def test_status_code_on_method_sugar_and_router_groups():
api = _api()
@api.post("/sugar", status_code=201)
def sugar(req, resp):
resp.media = {"ok": True}
group = api.group("/v1")
@group.post("/grouped", status_code=202)
def grouped(req, resp):
resp.media = {"ok": True}
assert api.requests.post("http://;/sugar").status_code == 201
assert api.requests.post("http://;/v1/grouped").status_code == 202
def test_status_code_on_class_based_view():
api = _api()
@api.route("/cbv", status_code=201)
class Items:
def on_post(self, req, resp):
resp.media = {"id": 1}
r = api.requests.post("http://;/cbv")
assert r.status_code == 201
def test_status_code_hook_short_circuit_still_wins():
api = _api()
def deny(req, resp):
resp.status_code = 403
resp.media = {"detail": "nope"}
@api.route("/guarded", methods=["POST"], status_code=201, before=[deny])
def guarded(req, resp): # pragma: no cover - skipped by the hook
resp.media = {"id": 1}
r = api.requests.post("http://;/guarded")
assert r.status_code == 403
def test_status_code_validation_error_still_422():
api = _api()
@api.route("/items", methods=["POST"], status_code=201)
async def create_item(req, resp, *, item: ItemIn):
resp.media = {"id": 1, "name": item.name}
ok = api.requests.post("http://;/items", json={"name": "thing"})
assert ok.status_code == 201
bad = api.requests.post("http://;/items", json={"name": 5.5})
assert bad.status_code == 422
def test_status_code_rejects_invalid_codes():
api = _api()
with pytest.raises(ValueError):
api.route("/bad", status_code=99)
with pytest.raises(ValueError):
api.route("/bad", status_code=1000)
# ---------------------------------------------------------------------------
# Item 63: status_code= keys the documented success response.
# ---------------------------------------------------------------------------
def test_status_code_documented_instead_of_200():
api = _api()
@api.route(
"/items", methods=["POST"], status_code=201, response_model=ItemOut
)
async def create_item(req, resp, *, item: ItemIn):
resp.media = {"id": 1, "name": item.name}
spec = _spec_of(api)
responses = spec["paths"]["/items"]["post"]["responses"]
assert "200" not in responses
assert responses["201"]["description"] == "Successful response"
schema = responses["201"]["content"]["application/json"]["schema"]
assert schema == {"$ref": "#/components/schemas/ItemOut"}
# Framework error responses are unaffected.
assert "422" in responses
validate(spec)
def test_status_code_204_documents_no_body():
api = _api()
@api.route("/items/{id:int}", methods=["DELETE"], status_code=204)
async def delete_item(req, resp, *, id):
pass
r = api.requests.delete("http://;/items/3")
assert r.status_code == 204
assert r.content == b""
spec = _spec_of(api)
responses = spec["paths"]["/items/{id}"]["delete"]["responses"]
assert "200" not in responses
assert "content" not in responses["204"]
validate(spec)
def test_status_code_examples_attach_to_declared_status():
api = _api()
@api.post(
"/items",
status_code=201,
examples={"made": {"value": {"id": 1, "name": "thing"}}},
)
def create_item(req, resp):
resp.media = {"id": 1, "name": "thing"}
spec = _spec_of(api)
responses = spec["paths"]["/items"]["post"]["responses"]
examples = responses["201"]["content"]["application/json"]["examples"]
assert examples["made"]["value"] == {"id": 1, "name": "thing"}
assert "200" not in responses
validate(spec)
def test_status_code_on_class_based_view_documented():
api = _api()
@api.route("/cbv", status_code=201)
class Items:
def on_post(self, req, resp):
resp.media = {"id": 1}
spec = _spec_of(api)
responses = spec["paths"]["/cbv"]["post"]["responses"]
assert "201" in responses
assert "200" not in responses
def test_include_router_passes_status_code_through():
router = Router(prefix="/items")
@router.post("", status_code=201)
def create_item(req, resp):
resp.media = {"id": 1}
api = _api()
api.include_router(router, prefix="/v1")
r = api.requests.post("http://;/v1/items")
assert r.status_code == 201
spec = _spec_of(api)
responses = spec["paths"]["/v1/items"]["post"]["responses"]
assert "201" in responses
assert "200" not in responses
# ---------------------------------------------------------------------------
# Item 67: the generated OpenAPI document is cached...
# ---------------------------------------------------------------------------
def test_apispec_is_cached_between_accesses():
api = _api()
@api.route("/one")
def one(req, resp):
resp.media = {"n": 1}
assert api.openapi._apispec is api.openapi._apispec
# The rendered YAML is cached too (immutable, so identity is safe).
assert api.openapi.openapi is api.openapi.openapi
def test_schema_response_serves_cached_document():
api = _api()
@api.route("/one")
def one(req, resp):
resp.media = {"n": 1}
first = api.requests.get("http://;/schema.yml")
assert first.status_code == 200
cached = api.openapi._spec_cache
assert cached is not None
second = api.requests.get("http://;/schema.yml")
assert second.text == first.text
assert api.openapi._spec_cache is cached # not rebuilt between requests
# JSON negotiation is served from the same cached spec.
as_json = api.requests.get(
"http://;/schema.yml", headers={"Accept": "application/json"}
)
assert as_json.json()["paths"] == yaml.safe_load(first.text)["paths"]
assert api.openapi._spec_cache is cached
# ---------------------------------------------------------------------------
# ... and invalidated when routes, schemas, or security schemes change.
# ---------------------------------------------------------------------------
def test_adding_route_after_first_render_regenerates():
api = _api()
@api.route("/early")
def early(req, resp):
resp.media = {"n": 1}
before = yaml.safe_load(api.requests.get("http://;/schema.yml").text)
assert "/later" not in before["paths"]
@api.route("/later")
def later(req, resp):
resp.media = {"n": 2}
after = yaml.safe_load(api.requests.get("http://;/schema.yml").text)
assert "/later" in after["paths"]
def test_adding_schema_after_first_render_regenerates():
api = _api()
@api.route("/early")
def early(req, resp):
resp.media = {"n": 1}
before = _spec_of(api)
assert "Registered" not in before.get("components", {}).get("schemas", {})
class Registered(BaseModel):
name: str
api.schema("Registered")(Registered)
after = _spec_of(api)
assert "Registered" in after["components"]["schemas"]
def test_adding_security_scheme_after_first_render_regenerates():
api = _api()
@api.route("/early")
def early(req, resp):
resp.media = {"n": 1}
before = _spec_of(api)
assert "bearer" not in before.get("components", {}).get("securitySchemes", {})
api.add_security_scheme("bearer", {"type": "http", "scheme": "bearer"})
after = _spec_of(api)
assert after["components"]["securitySchemes"]["bearer"] == {
"type": "http",
"scheme": "bearer",
}
def test_replacing_schema_same_name_regenerates():
api = _api()
class Thing(BaseModel):
name: str
api.openapi.add_schema("Thing", Thing)
before = _spec_of(api)
assert "name" in before["components"]["schemas"]["Thing"]["properties"]
class OtherThing(BaseModel):
label: str
api.openapi.add_schema("Thing", OtherThing, check_existing=False)
after = _spec_of(api)
assert "label" in after["components"]["schemas"]["Thing"]["properties"]
# ---------------------------------------------------------------------------
# Pydantic Form-model binding is documented as the form requestBody schema.
# ---------------------------------------------------------------------------
class ProfileForm(BaseModel):
name: str
age: int = 0
def _collect_refs(obj, acc):
if isinstance(obj, dict):
for key, value in obj.items():
if key == "$ref" and isinstance(value, str):
acc.append(value)
else:
_collect_refs(value, acc)
elif isinstance(obj, list):
for item in obj:
_collect_refs(item, acc)
return acc
@pytest.mark.parametrize("openapi_version", ["3.0.3", "3.1.0"])
def test_form_model_documents_urlencoded_request_body(openapi_version):
api = _api(openapi=openapi_version)
@api.route("/profiles", methods=["POST"])
async def create(req, resp, *, profile: ProfileForm = Form(...)):
resp.media = profile.model_dump()
spec = _spec_of(api)
body = spec["paths"]["/profiles"]["post"]["requestBody"]
assert body["required"] is True
# The model's fields are the form fields — not a "profile" property.
schema = body["content"]["application/x-www-form-urlencoded"]["schema"]
assert schema["type"] == "object"
assert set(schema["properties"]) == {"name", "age"}
assert schema["properties"]["name"]["type"] == "string"
assert schema["required"] == ["name"]
assert "profile" not in schema["properties"]
validate(spec)
def test_form_model_with_upload_file_documents_multipart():
api = _api()
class AvatarForm(BaseModel):
model_config = ConfigDict(arbitrary_types_allowed=True)
name: str
avatar: UploadFile
@api.route("/avatars", methods=["POST"])
async def create(req, resp, *, form: AvatarForm = Form(...)):
resp.media = {"ok": True}
spec = _spec_of(api)
body = spec["paths"]["/avatars"]["post"]["requestBody"]
assert body["required"] is True
# An UploadFile field forces multipart; the file part is a binary string.
schema = body["content"]["multipart/form-data"]["schema"]
assert schema["properties"]["avatar"] == {"type": "string", "format": "binary"}
assert schema["properties"]["name"]["type"] == "string"
validate(spec)
def test_form_model_optional_marker_body_not_required():
api = _api()
@api.route("/profiles", methods=["POST"])
async def create(req, resp, *, profile: ProfileForm = Form(None)):
resp.media = {"ok": True}
spec = _spec_of(api)
body = spec["paths"]["/profiles"]["post"]["requestBody"]
assert "required" not in body
validate(spec)
def test_form_model_hoists_nested_defs_into_components():
api = _api()
class Color(enum.Enum):
red = "red"
blue = "blue"
class PaintForm(BaseModel):
name: str
color: Color
@api.route("/paints", methods=["POST"])
async def create(req, resp, *, paint: PaintForm = Form(...)):
resp.media = {"ok": True}
spec = _spec_of(api)
schemas = spec["components"]["schemas"]
assert "Color" in schemas
assert "$defs" not in str(spec)
prefix = "#/components/schemas/"
for ref in _collect_refs(spec, []):
assert ref.startswith(prefix)
assert ref.removeprefix(prefix) in schemas, f"dangling ref: {ref}"
validate(spec)
def test_form_model_same_named_defs_get_distinct_components():
api = _api()
def make_form(colors):
Color = enum.Enum("Color", colors) # noqa: N806
class PickForm(BaseModel):
color: Color
return PickForm
FormA = make_form({"red": "red"}) # noqa: N806
FormB = make_form({"green": "green"}) # noqa: N806
@api.route("/a", methods=["POST"])
async def a(req, resp, *, pick: FormA = Form(...)):
resp.media = {"ok": True}
@api.route("/b", methods=["POST"])
async def b(req, resp, *, pick: FormB = Form(...)):
resp.media = {"ok": True}
spec = _spec_of(api)
schemas = spec["components"]["schemas"]
# Two distinct enums named Color must not merge into one component.
assert "Color" in schemas
assert "Color_2" in schemas
prefix = "#/components/schemas/"
for ref in _collect_refs(spec, []):
assert ref.removeprefix(prefix) in schemas, f"dangling ref: {ref}"
validate(spec)
def test_form_model_merges_with_scalar_form_and_file_markers():
api = _api()
@api.route("/mixed", methods=["POST"])
async def create(
req,
resp,
*,
profile: ProfileForm = Form(...),
note: str = Form("hi"),
attachment=File(...),
):
resp.media = {"ok": True}
spec = _spec_of(api)
body = spec["paths"]["/mixed"]["post"]["requestBody"]
assert body["required"] is True
# A File() marker alongside the model forces multipart.
schema = body["content"]["multipart/form-data"]["schema"]
assert set(schema["properties"]) == {"name", "age", "note", "attachment"}
assert schema["properties"]["attachment"] == {
"type": "string",
"format": "binary",
}
# Model-required fields and the required File() are listed; Form("hi") isn't.
assert set(schema["required"]) == {"name", "attachment"}
validate(spec)