mirror of
https://github.com/kennethreitz/responder.git
synced 2026-07-21 18:39:29 +00:00
222 lines
8.7 KiB
ReStructuredText
222 lines
8.7 KiB
ReStructuredText
Migrating to v9
|
|
===============
|
|
|
|
Responder 9.0 makes the v8.1 deprecation path the default behavior. Most
|
|
apps only need small cleanup: use the supported test-client helpers, pass
|
|
explicit route endpoints, and rely on precision-preserving JSON defaults.
|
|
|
|
The compatibility switches documented below are explicit and quiet. They are
|
|
there for apps that need to keep 8.x behavior while migrating.
|
|
|
|
Run your test suite with warnings surfaced to find any unrelated deprecations::
|
|
|
|
python -W error::DeprecationWarning -m pytest
|
|
|
|
Typed response contracts in 9.1
|
|
-------------------------------
|
|
|
|
Responder 9.1 treats every supported return annotation as an enforced response
|
|
contract. This now includes Pydantic models, dataclasses, typed dictionaries,
|
|
collections such as ``list[Item]``, unions, generic pagination models, and JSON
|
|
scalars. The same type drives runtime validation and serialization, OpenAPI,
|
|
and generated-client types::
|
|
|
|
@api.get("/items")
|
|
def list_items(req, resp) -> list[ItemOut]:
|
|
return rows
|
|
|
|
Earlier releases inferred only a narrower set of shapes. A route that returned
|
|
data inconsistent with a generic or scalar annotation may now fail closed with
|
|
``500``. Fix the returned value to match the annotation, or explicitly retain
|
|
mutation-only/unchecked behavior while migrating::
|
|
|
|
@api.get("/legacy", response_model=False)
|
|
def legacy(req, resp) -> list[ItemOut]:
|
|
resp.media = build_legacy_payload()
|
|
|
|
Unresolvable or unsupported return annotations now produce a registration
|
|
warning naming the route and the ``response_model=False`` escape hatch.
|
|
|
|
Alternate statuses can declare their own runtime and OpenAPI contracts through
|
|
``responses=``. Return the body and status together, or assign ``resp`` as
|
|
usual::
|
|
|
|
class NotFound(BaseModel):
|
|
detail: str
|
|
|
|
@api.get("/items/{item_id}", responses={404: NotFound})
|
|
def get_item(req, resp, *, item_id: str) -> ItemOut:
|
|
item = find_item(item_id)
|
|
if item is None:
|
|
return NotFound(detail="Item not found"), 404
|
|
return item
|
|
|
|
Use ``{"model": NotFound, "description": "..."}`` as the response value
|
|
when the generated OpenAPI response needs a custom description, headers, or
|
|
other metadata.
|
|
|
|
Multipart uploads stream to disk
|
|
--------------------------------
|
|
|
|
Multipart bodies now parse incrementally as they arrive: file parts spool to
|
|
temporary files (rolling to disk past ~1 MB) instead of being buffered in
|
|
RAM. Nothing changes in how you read uploads — ``File()`` markers,
|
|
``req.media("files")``, and ``req.media("form")`` all work as before, and
|
|
``UploadFile.save()`` copies disk-to-disk in chunks.
|
|
|
|
Two consequences of the body no longer being buffered:
|
|
|
|
* **The raw body is consumed by the parse.** ``await req.content`` after a
|
|
multipart parse raises a ``RuntimeError`` (previously it returned the full
|
|
buffered body). If a handler needs both the raw bytes and the parsed form,
|
|
await ``req.content`` *first* — parsing then falls back to the buffered
|
|
body, exactly like 8.x. Handlers using ``File()``/``Form()`` markers have
|
|
the form parsed before they run, so the raw multipart body is not available
|
|
to them at all (this matches Starlette and FastAPI).
|
|
|
|
* **Text fields share the streaming parser's limits.** ``req.media("form")``
|
|
on a multipart body now goes through the same parse as ``media("files")``:
|
|
at most 1000 parts and 1 MB per text field (oversized or malformed bodies
|
|
get a ``400``). Text fields with invalid UTF-8 are now decoded lossily
|
|
instead of being silently dropped.
|
|
|
|
Request bodies are capped at 100 MiB by default
|
|
-----------------------------------------------
|
|
|
|
``max_request_size`` now defaults to 100 MiB instead of unlimited; larger
|
|
bodies get a ``413``. The cap is enforced chunk-by-chunk on the wire, and
|
|
since multipart uploads spool to disk, raising it does not raise memory
|
|
use::
|
|
|
|
api = responder.API(max_request_size=5 * 1024**3) # allow 5 GiB uploads
|
|
api = responder.API(max_request_size=None) # pre-9.0 unlimited
|
|
|
|
Rate-limit errors use Problem Details
|
|
-------------------------------------
|
|
|
|
With the default ``API(problem_details=True)``, the rate limiter's
|
|
``429 Too Many Requests`` and fail-closed-backend ``503 Service
|
|
Unavailable`` responses now use the same RFC 9457
|
|
``application/problem+json`` envelope as framework-generated errors::
|
|
|
|
{"type": "about:blank", "title": "Too Many Requests",
|
|
"status": 429, "detail": "Rate limit exceeded."}
|
|
|
|
Previously the body was ``{"error": "rate limit exceeded"}`` — clients that
|
|
parse the ``error`` key need updating (or key off the status code and
|
|
``Retry-After``/``X-RateLimit-*`` headers, which are unchanged). To keep the
|
|
legacy body shape while migrating, pass ``API(problem_details=False)``,
|
|
which also keeps the legacy shape for all framework errors.
|
|
|
|
``trust_proxy_headers`` now rewrites the connection scope
|
|
----------------------------------------------------------
|
|
|
|
In 8.x, ``API(trust_proxy_headers=True)`` only changed the client IP recorded
|
|
by ``enable_logging``. It now honors the full set of forwarding headers —
|
|
RFC 7239 ``Forwarded``, plus ``X-Forwarded-Proto``/``-Host``/``-For`` and
|
|
``X-Real-IP`` — rewriting the request's scheme, host, and client address for
|
|
every layer: HTTPS detection, redirects, URL building, trusted-host
|
|
validation, and rate-limit keys. If you enabled the flag purely for logging,
|
|
logged IPs are unchanged; the scheme/host handling simply becomes correct
|
|
behind your proxy. Leave the flag off (the default) when Responder is
|
|
directly exposed.
|
|
|
|
``api.session()`` was removed
|
|
-----------------------------
|
|
|
|
The legacy test-client accessor ``api.session()`` has been removed. Use the
|
|
:attr:`~responder.API.requests` property instead::
|
|
|
|
r = api.requests.get("http://;/hello")
|
|
|
|
If you relied on ``session(base_url=...)`` for a custom base URL, construct
|
|
the client through the supported helper::
|
|
|
|
client = api.test_client(base_url="http://testserver")
|
|
|
|
Explicit ``port=`` now wins over ``PORT``
|
|
-----------------------------------------
|
|
|
|
When ``api.run()`` / ``api.serve()`` receive both an explicit ``port=`` and
|
|
a conflicting ``PORT`` environment variable, the explicit argument wins::
|
|
|
|
# PORT=9000 in the environment:
|
|
api.run(port=8000) # binds 8000
|
|
api.run(port=9000) # binds 9000
|
|
api.run() # binds 9000
|
|
|
|
To keep environment-first behavior, resolve the environment yourself::
|
|
|
|
api.run(port=int(os.environ.get("PORT", 8000)))
|
|
|
|
or use the legacy compatibility switch while migrating::
|
|
|
|
api.run(port=8000, port_precedence="env")
|
|
|
|
Bare ``add_route()`` static fallback
|
|
------------------------------------
|
|
|
|
Calling ``api.add_route(route)`` with no endpoint now raises by default
|
|
instead of implicitly registering a *default* route that serves
|
|
``static/index.html`` for every unmatched request. Pass an endpoint
|
|
explicitly instead::
|
|
|
|
import pathlib
|
|
|
|
async def spa(req, resp):
|
|
resp.html = (pathlib.Path("static") / "index.html").read_text()
|
|
|
|
api.add_route("/", spa, default=True)
|
|
|
|
Static *assets* are unaffected: the ``static_dir`` / ``static_route`` mount
|
|
keeps working as-is.
|
|
|
|
If you need the old fallback while migrating, opt in explicitly::
|
|
|
|
api = responder.API(implicit_static_fallback=True)
|
|
|
|
``Decimal`` serializes as a JSON string
|
|
---------------------------------------
|
|
|
|
Assigning a bare :class:`decimal.Decimal` to ``resp.media`` now serializes it
|
|
as a JSON string, preserving precision.
|
|
|
|
Choose a representation explicitly when your API contract needs a number::
|
|
|
|
resp.media = {"price": str(total)} # exact, v9's default
|
|
resp.media = {"price": float(total)} # lossy, JSON number
|
|
|
|
or keep floats everywhere with the compatibility flag or a custom encoder::
|
|
|
|
api = responder.API(json_decimal="float")
|
|
|
|
def encoder(obj):
|
|
if isinstance(obj, decimal.Decimal):
|
|
return float(obj)
|
|
raise TypeError # fall back to the built-in conversions
|
|
|
|
api = responder.API(encoder=encoder)
|
|
|
|
GraphQL: ``400`` with partial data
|
|
-----------------------------------
|
|
|
|
The GraphQL extension now returns HTTP ``200`` for execution results that
|
|
contain both ``data`` and ``errors`` (a *partial* result, e.g. one resolver
|
|
failed while others succeeded). Per the `GraphQL-over-HTTP specification
|
|
<https://graphql.github.io/graphql-over-http/>`_, these are well-formed
|
|
GraphQL responses.
|
|
|
|
Requests that produce *no* data (validation or request errors) keep their
|
|
``400``. Inspect the ``errors`` key of the response body instead of relying
|
|
on the status code alone::
|
|
|
|
api.graphql("/graph", schema=schema)
|
|
|
|
result = client.post("/graph", json={"query": query}).json()
|
|
if result.get("errors"):
|
|
... # handle errors, regardless of HTTP status
|
|
|
|
To preserve the legacy partial-data status while migrating, pass::
|
|
|
|
api.graphql("/graph", schema=schema, partial_data_status=400)
|