Logging requests as curl¶
The most common use of curlify3 is a log line that can be pasted into a terminal to replay the request. The recipes below log every request at the point where the framework or the client hands it over. All of them drop the headers every client sends on its own and mask the credentials — see Filtering and masking headers.
import logging
from curlify3 import SENSITIVE_HEADERS
logger = logging.getLogger("app.requests")
# shared by the recipes below
NOISE = ["accept", "accept-encoding", "connection", "user-agent"]
Log before the request is handled, so the command is captured even for the requests the handler then fails on — the ones worth replaying.
Incoming requests¶
FastAPI / starlette¶
from fastapi import FastAPI, Request
from curlify3 import to_curl_async
app = FastAPI()
@app.middleware("http")
async def log_request_as_curl(request: Request, call_next):
curl = await to_curl_async(request, exclude_headers=NOISE, mask_headers=SENSITIVE_HEADERS)
logger.info("incoming request: %s", curl)
return await call_next(request)
Reading the body in a middleware is safe: starlette caches what to_curl_async() consumed and replays it, so the route handler still receives the full body (starlette 0.28+). An authenticated POST arrives in the log as:
incoming request: curl -X POST -H 'host: api.example.com' -H 'authorization: Bearer ***' -H 'content-type: application/json' -d '{"item":"book","qty":2}' http://api.example.com/orders
Every value in that command was chosen by the client. Quoting and untrusted values is what makes it safe to paste anyway.
Django¶
from curlify3 import to_curl
class CurlLoggingMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
curl = to_curl(request, exclude_headers=NOISE, mask_headers=SENSITIVE_HEADERS)
logger.info("incoming request: %s", curl)
return self.get_response(request)
Add it to MIDDLEWARE. Django buffers the body when it is first read, so the view can still read request.body and request.POST. The sync to_curl() is the right call in async views too. A request whose stream was consumed without buffering — request.read(), or multipart parsing before the middleware runs — renders without -d.
Flask / werkzeug¶
import flask
from curlify3 import to_curl
app = flask.Flask(__name__)
@app.before_request
def log_request_as_curl():
curl = to_curl(flask.request, exclude_headers=NOISE, mask_headers=SENSITIVE_HEADERS)
logger.info("incoming request: %s", curl)
The body is read with caching on and form parsing off, so request.form and request.files work afterwards. A form that was parsed before rendering is encoded again from its fields; a parsed multipart form renders without -d.
aiohttp server¶
from aiohttp import web
from curlify3 import to_curl_async
@web.middleware
async def log_request_as_curl(request: web.Request, handler):
curl = await to_curl_async(request, exclude_headers=NOISE, mask_headers=SENSITIVE_HEADERS)
logger.info("incoming request: %s", curl)
return await handler(request)
app = web.Application(middlewares=[log_request_as_curl])
request.read() caches the body, so the handler reads it again.
tornado¶
import tornado.web
from curlify3 import to_curl
class LoggedHandler(tornado.web.RequestHandler):
def prepare(self):
curl = to_curl(self.request, exclude_headers=NOISE, mask_headers=SENSITIVE_HEADERS)
logger.info("incoming request: %s", curl)
tornado reads the body before prepare() runs, so the incoming request renders synchronously.
Outgoing requests¶
httpx / httpx2¶
Event hooks see the request just before it is sent:
import httpx
from curlify3 import to_curl, to_curl_async
def log_request(request: httpx.Request) -> None:
logger.info("outgoing request: %s", to_curl(request, mask_headers=SENSITIVE_HEADERS))
client = httpx.Client(event_hooks={"request": [log_request]})
async def log_request_async(request: httpx.Request) -> None:
logger.info("outgoing request: %s", await to_curl_async(request, mask_headers=SENSITIVE_HEADERS))
async_client = httpx.AsyncClient(event_hooks={"request": [log_request_async]})
httpx2 takes the same hooks; its commands carry --http2.
requests / niquests¶
requests has no hook before sending, but every response carries the request it was sent for:
import requests
from curlify3 import to_curl
def log_request(response: requests.Response, *args, **kwargs) -> None:
logger.info("request: %s", to_curl(response.request, mask_headers=SENSITIVE_HEADERS))
session = requests.Session()
session.hooks["response"].append(log_request)
A redirect calls the hook once per hop, each with its own request. niquests sessions take the same hook.
aiohttp client¶
A client middleware (aiohttp 3.12+) sees the outgoing ClientRequest:
import aiohttp
from curlify3 import to_curl_async
async def log_request(request: aiohttp.ClientRequest, handler):
logger.info("outgoing request: %s", await to_curl_async(request, mask_headers=SENSITIVE_HEADERS))
return await handler(request)
async with aiohttp.ClientSession(middlewares=(log_request,)) as session:
await session.post("https://httpbin.org/post", json={"hello": "world"})
Rendering does not consume the payload: in-memory bodies hand back their value, files seek back, and async iterables are cached and replayed when the request is sent. That non-consuming read needs aiohttp 3.12.1+; below it, streaming bodies render without -d.
In tests¶
The pytest plugin records the requests a test sends and attaches the commands to the report of a failing test, without any hooks of your own. Robot Framework suites get the same from the Robot Framework library.