From c6b933e231a40d26348a2e32d30aec854763b19f Mon Sep 17 00:00:00 2001 From: NEFORCEO Date: Sat, 25 Jul 2026 13:41:33 +0700 Subject: [PATCH 01/13] chore: update mkdocs.yml --- mkdocs.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/mkdocs.yml b/mkdocs.yml index 60607e2..0d1b9e1 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -109,6 +109,7 @@ nav: - Middleware: - en/tutorial/middleware/index.md - en/tutorial/middleware/creating.md + - en/tutorial/middleware/function.md - en/tutorial/middleware/examples.md - Security: - en/tutorial/security/index.md @@ -177,6 +178,7 @@ nav: - Middleware: - ru/tutorial/middleware/index.md - ru/tutorial/middleware/creating.md + - ru/tutorial/middleware/function.md - ru/tutorial/middleware/examples.md - Security: - ru/tutorial/security/index.md From 0c8272add1c7ead4d6e98e8e73b696c414210d5d Mon Sep 17 00:00:00 2001 From: NEFORCEO Date: Sat, 25 Jul 2026 13:41:35 +0700 Subject: [PATCH 02/13] docs: update middleware.md --- docs/en/reference/middleware.md | 45 +++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/docs/en/reference/middleware.md b/docs/en/reference/middleware.md index 4f82abe..5b4ab8e 100644 --- a/docs/en/reference/middleware.md +++ b/docs/en/reference/middleware.md @@ -109,6 +109,51 @@ Methods are called automatically by `HTTPClient`. --- +## `app.middleware(middleware_type)` + +Registers a function-based middleware, FastAPI-style. See the +[Function-based Middleware](../tutorial/middleware/function.md) tutorial +for the full guide. + +```python +@app.middleware("http") +async def mw(request: Request, call_next): + return await call_next(request) +``` + +| Parameter | Type | Description | +|-----------|------|--------------| +| `middleware_type` | `Literal["http"]` | Only `"http"` is currently supported — raises `ValueError` otherwise | + +The decorated function receives `(request: Request, call_next)` and must +return (or replace) the result of `await call_next(request)`. Multiple +registered middleware nest like a stack — the first registered is +outermost. + +## Request + +Passed to `@app.middleware("http")` handlers. Represents the outgoing +request before it's sent. + +```python +from fasthttp import Request +``` + +| Attribute | Type | Description | +|-----------|------|--------------| +| `method` | `str` | HTTP method being sent | +| `url` | `httpx.URL` | Structured URL (`.host`, `.port`, `.scheme`, `.path`, `.params`) | +| `host` | `str \| None` | Shortcut for `request.url.host` | +| `headers` | `dict[str, str]` | Mutable — reaches the outgoing request | +| `query_params` | `dict[str, Any]` | Read-only snapshot | +| `json` | `dict \| None` | Read-only snapshot | +| `content` | `Any` | Read-only snapshot | +| `route` | `Route` | The `Route` being executed | +| `app` | `FastHTTP` | The application instance | +| `state` | `SimpleNamespace` | Per-request scratch space | + +--- + ## CookieJar Cookie storage passed to `FastHTTP(cookie_jar=...)`. Captures `Set-Cookie` headers from responses and injects cookies into subsequent requests. From a71ed812b6f298873491b8a138ec61e7762637b3 Mon Sep 17 00:00:00 2001 From: NEFORCEO Date: Sat, 25 Jul 2026 13:41:37 +0700 Subject: [PATCH 03/13] docs: create function.md --- docs/en/tutorial/middleware/function.md | 137 ++++++++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 docs/en/tutorial/middleware/function.md diff --git a/docs/en/tutorial/middleware/function.md b/docs/en/tutorial/middleware/function.md new file mode 100644 index 0000000..032b25c --- /dev/null +++ b/docs/en/tutorial/middleware/function.md @@ -0,0 +1,137 @@ +# Function-based Middleware + +`@app.middleware("http")` registers a plain async function as middleware, FastAPI-style — no `BaseMiddleware` subclass required. + +```python +import time +from fasthttp import FastHTTP, Request +from fasthttp.response import Response + +app = FastHTTP() + + +@app.middleware("http") +async def timing(request: Request, call_next): + start = time.monotonic() + response = await call_next(request) + if response is not None: + response.headers["X-Elapsed"] = str(time.monotonic() - start) + return response + + +@app.get(url="https://httpbin.org/get") +async def get_data(resp: Response) -> dict: + return resp.json() + + +app.run() +``` + +!!! note "Only `"http"` is supported for now" + WebSocket (`@app.ws`) and GraphQL (`@app.graphql`) requests don't run + through `HTTPClient`, so they don't go through this decorator yet. + Calling `app.middleware("ws")` or `app.middleware("graphql")` raises + `ValueError`. Use [`BaseMiddleware`](creating.md) or + [event hooks](../../middleware.md#event-hooks) for those in the meantime. + +## The `call_next` pattern + +Unlike `BaseMiddleware`, which splits `request()`/`response()` into two +separate methods, a function middleware wraps the **entire** call in one +coroutine — including retries, redirects, class-based `BaseMiddleware`, +and the route handler itself: + +``` +add_trace_id ─┐ ┌─ (everything after call_next) + await call_next(request) ──▶ retries/redirects/BaseMiddleware/handler + (everything before call_next) ◀──────┘ +``` + +Everything **before** `await call_next(request)` runs before the request is +sent. Everything **after** runs once the response (or `None`, on failure) +comes back — including the route handler's return value already being +processed. + +Register several and they nest like a stack — the **first registered is +outermost**: + +```python +@app.middleware("http") +async def outer(request: Request, call_next): + print("outer: before") + response = await call_next(request) + print("outer: after") + return response + + +@app.middleware("http") +async def inner(request: Request, call_next): + print("inner: before") + response = await call_next(request) + print("inner: after") + return response +``` + +Order: `outer: before` → `inner: before` → request sent → `inner: after` → `outer: after`. + +## Short-circuiting + +Return without calling `call_next(request)` to skip the request entirely +— useful for auth guards or cache short-circuits: + +```python +@app.middleware("http") +async def block_blank_host(request: Request, call_next): + if not request.host: + return None + return await call_next(request) +``` + +## `Request` + +`Request` describes the outgoing request *before* it's sent. Unlike an +incoming ASGI request, the body is already fully built — there's nothing +to stream — so only `.headers` is mutable and actually reaches the +request. `.json` / `.content` / `.query_params` are read-only snapshots +for inspection (logging, tracing) — mutating them has no effect, matching +how `BaseMiddleware.request()`'s `kwargs["json"]`/`kwargs["data"]` already +behave. + +| Attribute | Type | Description | +|-----------|------|--------------| +| `method` | `str` | HTTP method being sent | +| `url` | `httpx.URL` | Structured URL — `.host`, `.port`, `.scheme`, `.path`, `.params` | +| `host` | `str \| None` | Shortcut for `request.url.host` | +| `headers` | `dict[str, str]` | **Mutable** — changes are sent with the request | +| `query_params` | `dict[str, Any]` | Read-only snapshot of the query params that will be sent | +| `json` | `dict \| None` | Read-only snapshot of the JSON body, if any | +| `content` | `Any` | Read-only snapshot of the raw body/form data, if any | +| `route` | `Route` | The `Route` being executed — `tags`, `response_model`, etc. | +| `app` | `FastHTTP` | The application instance — access app-level config from middleware | +| `state` | `SimpleNamespace` | Per-request scratch space — stash data before `call_next` and read it after | + +### Using `.state` to pass data across the call + +```python +@app.middleware("http") +async def timing(request: Request, call_next): + request.state.start = time.monotonic() + response = await call_next(request) + elapsed = time.monotonic() - request.state.start + if response is not None: + response.headers["X-Elapsed"] = f"{elapsed:.3f}" + return response +``` + +## Mixing with class-based middleware + +Function middleware (`@app.middleware("http")`) and `BaseMiddleware` +(`FastHTTP(middleware=[...])`) can be used together. Function middleware +always wraps **outside** — it sees the request first and the response +last, with all `BaseMiddleware` instances and the route handler running +in between. + +## See also + +- [Creating Middleware](creating.md) — class-based `BaseMiddleware` API +- [Middleware Reference](../../reference/middleware.md) — full API reference From d3bafdb9015f6c4013fc4cee883aedc6af590770 Mon Sep 17 00:00:00 2001 From: NEFORCEO Date: Sat, 25 Jul 2026 13:41:40 +0700 Subject: [PATCH 04/13] docs: update docs/en/tutorial/middleware/index.md --- docs/en/tutorial/middleware/index.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/en/tutorial/middleware/index.md b/docs/en/tutorial/middleware/index.md index b27cbfd..dc00147 100644 --- a/docs/en/tutorial/middleware/index.md +++ b/docs/en/tutorial/middleware/index.md @@ -55,6 +55,7 @@ Output: ## Next steps - [Creating Middleware](creating.md) — BaseMiddleware API, class attributes, pipe chaining +- [Function-based Middleware](function.md) — `@app.middleware("http")`, FastAPI-style - [Examples](examples.md) — auth, logging, timing, method filtering, toggle ## Comparison with dependencies From d2ba19aa0295835105c3e3e062c4a90828731736 Mon Sep 17 00:00:00 2001 From: NEFORCEO Date: Sat, 25 Jul 2026 13:41:42 +0700 Subject: [PATCH 05/13] docs: update middleware.md --- docs/ru/reference/middleware.md | 45 +++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/docs/ru/reference/middleware.md b/docs/ru/reference/middleware.md index bd7b9f4..2a1ca07 100644 --- a/docs/ru/reference/middleware.md +++ b/docs/ru/reference/middleware.md @@ -109,6 +109,51 @@ manager = MiddlewareManager([AuthMiddleware(), LoggingMiddleware()]) --- +## `app.middleware(middleware_type)` + +Регистрирует function-based middleware в стиле FastAPI. Полное +руководство — в туториале +[Function-based Middleware](../tutorial/middleware/function.md). + +```python +@app.middleware("http") +async def mw(request: Request, call_next): + return await call_next(request) +``` + +| Параметр | Тип | Описание | +|----------|-----|----------| +| `middleware_type` | `Literal["http"]` | Пока поддерживается только `"http"` — иначе `ValueError` | + +Декорируемая функция принимает `(request: Request, call_next)` и должна +вернуть (или заменить) результат `await call_next(request)`. Несколько +зарегистрированных middleware вкладываются как стек — первая +зарегистрированная становится самой внешней. + +## Request + +Передаётся в обработчики `@app.middleware("http")`. Представляет +исходящий запрос до отправки. + +```python +from fasthttp import Request +``` + +| Атрибут | Тип | Описание | +|---------|-----|----------| +| `method` | `str` | HTTP-метод отправляемого запроса | +| `url` | `httpx.URL` | Структурированный URL (`.host`, `.port`, `.scheme`, `.path`, `.params`) | +| `host` | `str \| None` | Шорткат для `request.url.host` | +| `headers` | `dict[str, str]` | Мутабельно — уходит вместе с запросом | +| `query_params` | `dict[str, Any]` | Read-only снимок | +| `json` | `dict \| None` | Read-only снимок | +| `content` | `Any` | Read-only снимок | +| `route` | `Route` | Выполняемый `Route` | +| `app` | `FastHTTP` | Экземпляр приложения | +| `state` | `SimpleNamespace` | Scratch-пространство на один запрос | + +--- + ## CookieJar Хранилище кук, передаётся в `FastHTTP(cookie_jar=...)`. Перехватывает `Set-Cookie` из ответов и подставляет куки в последующие запросы. From f2f5a05ac63a598ebbd2e35276c43d1c1a29b9bc Mon Sep 17 00:00:00 2001 From: NEFORCEO Date: Sat, 25 Jul 2026 13:41:47 +0700 Subject: [PATCH 06/13] docs: create function.md --- docs/ru/tutorial/middleware/function.md | 138 ++++++++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 docs/ru/tutorial/middleware/function.md diff --git a/docs/ru/tutorial/middleware/function.md b/docs/ru/tutorial/middleware/function.md new file mode 100644 index 0000000..b59db8a --- /dev/null +++ b/docs/ru/tutorial/middleware/function.md @@ -0,0 +1,138 @@ +# Function-based Middleware + +`@app.middleware("http")` регистрирует обычную async-функцию как middleware — в стиле FastAPI, без наследования от `BaseMiddleware`. + +```python +import time +from fasthttp import FastHTTP, Request +from fasthttp.response import Response + +app = FastHTTP() + + +@app.middleware("http") +async def timing(request: Request, call_next): + start = time.monotonic() + response = await call_next(request) + if response is not None: + response.headers["X-Elapsed"] = str(time.monotonic() - start) + return response + + +@app.get(url="https://httpbin.org/get") +async def get_data(resp: Response) -> dict: + return resp.json() + + +app.run() +``` + +!!! note "Пока поддерживается только `"http"`" + WebSocket (`@app.ws`) и GraphQL (`@app.graphql`) запросы не проходят + через `HTTPClient`, поэтому через этот декоратор пока не идут. + Вызов `app.middleware("ws")` или `app.middleware("graphql")` + выбрасывает `ValueError`. Пока используйте + [`BaseMiddleware`](creating.md) или + [event hooks](../../middleware.md#event-hooks). + +## Паттерн `call_next` + +В отличие от `BaseMiddleware`, где `request()`/`response()` — два разных +метода, function-middleware оборачивает **весь** вызов одной корутиной — +включая retry, редиректы, class-based `BaseMiddleware` и сам обработчик +роута: + +``` +add_trace_id ─┐ ┌─ (всё после call_next) + await call_next(request) ──▶ retries/redirects/BaseMiddleware/handler + (всё до call_next) ◀──────────────────┘ +``` + +Всё, что **до** `await call_next(request)`, выполняется до отправки +запроса. Всё, что **после**, — уже после того как пришёл ответ (или +`None` при ошибке), причём результат обработчика роута к этому моменту +уже обработан. + +Зарегистрируй несколько — они вложатся как стек, **первая +зарегистрированная — самая внешняя**: + +```python +@app.middleware("http") +async def outer(request: Request, call_next): + print("outer: before") + response = await call_next(request) + print("outer: after") + return response + + +@app.middleware("http") +async def inner(request: Request, call_next): + print("inner: before") + response = await call_next(request) + print("inner: after") + return response +``` + +Порядок: `outer: before` → `inner: before` → запрос отправлен → `inner: after` → `outer: after`. + +## Короткое замыкание + +Верни значение, не вызывая `call_next(request)`, чтобы полностью +пропустить запрос — полезно для auth-guard'ов или кэш-шорткатов: + +```python +@app.middleware("http") +async def block_blank_host(request: Request, call_next): + if not request.host: + return None + return await call_next(request) +``` + +## `Request` + +`Request` описывает исходящий запрос *до* отправки. В отличие от +входящего ASGI-запроса, тело уже полностью собрано — стримить нечего, +поэтому мутабелен только `.headers`, и только он реально влияет на +отправляемый запрос. `.json` / `.content` / `.query_params` — это +read-only снимки для инспекции (логирование, трейсинг) — их изменение +ни на что не влияет, ровно как и `kwargs["json"]`/`kwargs["data"]` в +`BaseMiddleware.request()` уже сегодня. + +| Атрибут | Тип | Описание | +|---------|-----|----------| +| `method` | `str` | HTTP-метод отправляемого запроса | +| `url` | `httpx.URL` | Структурированный URL — `.host`, `.port`, `.scheme`, `.path`, `.params` | +| `host` | `str \| None` | Шорткат для `request.url.host` | +| `headers` | `dict[str, str]` | **Мутабельно** — изменения уходят вместе с запросом | +| `query_params` | `dict[str, Any]` | Read-only снимок query-параметров, которые будут отправлены | +| `json` | `dict \| None` | Read-only снимок JSON-тела, если есть | +| `content` | `Any` | Read-only снимок сырого тела/form-data, если есть | +| `route` | `Route` | Выполняемый `Route` — `tags`, `response_model` и т.д. | +| `app` | `FastHTTP` | Экземпляр приложения — доступ к конфигу уровня app из middleware | +| `state` | `SimpleNamespace` | Scratch-пространство на один запрос — положи данные до `call_next`, прочитай после | + +### `.state` для передачи данных между фазами + +```python +@app.middleware("http") +async def timing(request: Request, call_next): + request.state.start = time.monotonic() + response = await call_next(request) + elapsed = time.monotonic() - request.state.start + if response is not None: + response.headers["X-Elapsed"] = f"{elapsed:.3f}" + return response +``` + +## Совмещение с class-based middleware + +Function middleware (`@app.middleware("http")`) и `BaseMiddleware` +(`FastHTTP(middleware=[...])`) можно использовать вместе. Function +middleware всегда оборачивает **снаружи** — видит запрос первой и ответ +последней, а все `BaseMiddleware` и обработчик роута выполняются между +этими точками. + +## Смотри также + +- [Создание Middleware](creating.md) — API class-based `BaseMiddleware` +- [Middleware Reference](../../reference/middleware.md) — полный справочник API From 44f0108a58445f141f83013aeb83245c989b48dd Mon Sep 17 00:00:00 2001 From: NEFORCEO Date: Sat, 25 Jul 2026 13:41:49 +0700 Subject: [PATCH 07/13] docs: update docs/ru/tutorial/middleware/index.md --- docs/ru/tutorial/middleware/index.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/ru/tutorial/middleware/index.md b/docs/ru/tutorial/middleware/index.md index 2c10545..4a1e547 100644 --- a/docs/ru/tutorial/middleware/index.md +++ b/docs/ru/tutorial/middleware/index.md @@ -56,6 +56,7 @@ app = FastHTTP(middleware=[LoggingMiddleware()]) ## Далее - [Создание Middleware](creating.md) — API BaseMiddleware, атрибуты класса, pipe-чейнинг +- [Function-based Middleware](function.md) — `@app.middleware("http")` в стиле FastAPI - [Примеры](examples.md) — auth, логирование, тайминги, фильтр по методам, toggle ## Сравнение с зависимостями From e207bf6569207e98e62c0adbded8eef2447cae11 Mon Sep 17 00:00:00 2001 From: NEFORCEO Date: Sat, 25 Jul 2026 13:41:51 +0700 Subject: [PATCH 08/13] update fasthttp/__init__.py --- fasthttp/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/fasthttp/__init__.py b/fasthttp/__init__.py index 45c77ce..6938ba3 100644 --- a/fasthttp/__init__.py +++ b/fasthttp/__init__.py @@ -14,6 +14,7 @@ RetryMiddleware, SessionMiddleware, ) +from .request import Request from .routing import Router from .session import AsyncSession from .sse import SSEEvent @@ -34,6 +35,7 @@ "MiddlewareChain", "MiddlewareManager", "OAuth2ClientCredentials", + "Request", "RetryMiddleware", "Router", "SSEEvent", From 95cc53db4103320eb17e21831c15e68dc9981ffc Mon Sep 17 00:00:00 2001 From: NEFORCEO Date: Sat, 25 Jul 2026 13:41:53 +0700 Subject: [PATCH 09/13] update app.py --- fasthttp/app.py | 90 +++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 88 insertions(+), 2 deletions(-) diff --git a/fasthttp/app.py b/fasthttp/app.py index ae84d53..faa5d5a 100644 --- a/fasthttp/app.py +++ b/fasthttp/app.py @@ -43,6 +43,7 @@ from .openapi.generator import generate_openapi_schema from .openapi.swagger import get_not_found_html, get_redoc_html, get_swagger_html from .openapi.urls import build_docs_urls +from .request import Request from .routing import Route, Router from .security import Security from .sse import SSEEvent @@ -55,7 +56,14 @@ from .auth import BasicAuth, BearerAuth, DigestAuth, OAuth2ClientCredentials from .response import Response - from .types import DefaultEncoding, FileUpload, HTTPMethod, RequestsOptional + from .types import ( + CallNext, + DefaultEncoding, + FileUpload, + HTTPMethod, + HTTPMiddlewareFunc, + RequestsOptional, + ) class FastHTTP: @@ -573,6 +581,7 @@ async def lifespan(app: FastHTTP): self._ws_routes: list[dict] = [] self._sse_routes: list[dict] = [] + self._http_function_middleware: list[HTTPMiddlewareFunc] = [] self.event_hooks = EventHooks() @@ -1461,6 +1470,59 @@ async def handle_timeout(route, exc): """ return self.event_hooks.exception_handler(exc_type=exc_type) + def middleware( + self, + middleware_type: Annotated[ + Literal["http"], + Doc( + """ + Kind of traffic the middleware applies to. + + Only ``"http"`` is currently supported — it wraps every + plain HTTP route (``@app.get``/``@app.post``/etc). WebSocket + and GraphQL requests don't go through this yet, since they + don't run through the same HTTPClient execution path. + """ + ), + ], + ) -> Callable[[HTTPMiddlewareFunc], HTTPMiddlewareFunc]: + """ + Register a function-based middleware, FastAPI-style. + + The decorated function receives the outgoing :class:`Request` and a + ``call_next`` callable. It must call and return (or replace) + ``await call_next(request)`` — everything before that call runs + before the request is sent, everything after runs once the + response (or ``None`` on failure) comes back. + + Registered middleware wraps *around* the whole per-request + pipeline (retries, redirects, class-based ``BaseMiddleware``, + the route handler) — the first registered middleware is outermost. + + Example: + ```python + @app.middleware("http") + async def timing(request: Request, call_next): + start = time.monotonic() + response = await call_next(request) + if response is not None: + response.headers["X-Elapsed"] = str(time.monotonic() - start) + return response + ``` + """ + if middleware_type != "http": + msg = ( + f"middleware type {middleware_type!r} is not supported yet — " + "only 'http' is currently implemented" + ) + raise ValueError(msg) + + def decorator(func: HTTPMiddlewareFunc) -> HTTPMiddlewareFunc: + self._http_function_middleware.append(func) + return func + + return decorator + def _log_result( self, route: Route, elapsed: float, result: Response | None ) -> None: @@ -1725,10 +1787,34 @@ async def _run_route( self, client: httpx.AsyncClient, route: Route ) -> tuple[Route, float, Response | None]: start = time.perf_counter() - result = await self.client.send(client, route) + + async def call_actual(request: Request) -> Response | None: + return await self.client.send(client, route, extra_headers=request.headers) + + handler: CallNext = call_actual + for mw in reversed(self._http_function_middleware): + handler = self._wrap_http_middleware(mw, handler) + + request = Request( + method=route.method, + url=route.url, + route=route, + app=self, + headers={}, + ) + result = await handler(request) elapsed = (time.perf_counter() - start) * 1000 return route, elapsed, result + @staticmethod + def _wrap_http_middleware( + mw: HTTPMiddlewareFunc, next_handler: CallNext + ) -> CallNext: + async def wrapped(request: Request) -> Response | None: + return await mw(request, next_handler) + + return wrapped + def run(self, tags: list[str] | None = None) -> None: """ Run all registered HTTP requests. From e1b83555a01423780050ea6f125872120d368b37 Mon Sep 17 00:00:00 2001 From: NEFORCEO Date: Sat, 25 Jul 2026 13:41:56 +0700 Subject: [PATCH 10/13] update client.py --- fasthttp/client.py | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/fasthttp/client.py b/fasthttp/client.py index 98ed7fb..1f3c5f9 100644 --- a/fasthttp/client.py +++ b/fasthttp/client.py @@ -142,7 +142,12 @@ def _validate_request(self, route: Route) -> bool: self.logger.error("Request validation failed: %s", e) return False - async def _prepare_config(self, route: Route, config: dict) -> dict: + async def _prepare_config( + self, + route: Route, + config: dict, + extra_headers: dict[str, str] | None = None, + ) -> dict: config = dict(config) headers = dict(config.get("headers") or {}) headers.setdefault("User-Agent", f"fasthttp/{__version__}") @@ -150,6 +155,9 @@ async def _prepare_config(self, route: Route, config: dict) -> dict: if self.startup_uuid: headers.setdefault("X-Request-ID", self.startup_uuid) + if extra_headers: + headers.update(extra_headers) + config["headers"] = headers if self.middleware_manager: @@ -454,12 +462,18 @@ async def _follow_redirect( await self._handle_error(route, config, e, FastHTTPRequestError) return None - async def send(self, client: httpx.AsyncClient, route: Route) -> Response | None: # noqa: C901 + async def send( # noqa: C901 + self, + client: httpx.AsyncClient, + route: Route, + *, + extra_headers: dict[str, str] | None = None, + ) -> Response | None: if not self._validate_request(route): return None config = self.request_configs.get(route.method, {}) - config = await self._prepare_config(route, config) + config = await self._prepare_config(route, config, extra_headers=extra_headers) if self._has_event_hooks: await self.event_hooks.process_request(route, config) From 9bfd5356b76c3a3aec2e86788ef899d9414a85db Mon Sep 17 00:00:00 2001 From: NEFORCEO Date: Sat, 25 Jul 2026 13:41:59 +0700 Subject: [PATCH 11/13] feat: create request.py --- fasthttp/request.py | 80 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 fasthttp/request.py diff --git a/fasthttp/request.py b/fasthttp/request.py new file mode 100644 index 0000000..0c9bda8 --- /dev/null +++ b/fasthttp/request.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +from types import SimpleNamespace +from typing import TYPE_CHECKING, Annotated, Any + +import httpx +from annotated_doc import Doc + +if TYPE_CHECKING: + from .app import FastHTTP + from .routing import Route + + +class Request: + """ + Outgoing HTTP request passed to ``@app.middleware("http")`` handlers. + + Unlike an ASGI server request, the body of an outgoing request is + already fully built before any middleware runs — there is nothing to + stream. ``.json`` / ``.content`` / ``.query_params`` are therefore + read-only snapshots of what ``route`` will send; only ``.headers`` + is mutable and actually reaches the request that gets sent. + + Example: + ```python + @app.middleware("http") + async def add_trace_id(request: Request, call_next): + request.headers["X-Trace-Id"] = str(uuid.uuid4()) + response = await call_next(request) + return response + ``` + """ + + __slots__ = ("_url", "app", "headers", "method", "route", "state") + + def __init__( + self, + method: Annotated[str, Doc("HTTP method of the request being sent.")], + url: Annotated[str, Doc("Resolved target URL.")], + route: Annotated[Route, Doc("The Route being executed.")], + app: Annotated[FastHTTP, Doc("The FastHTTP application instance.")], + headers: Annotated[ + dict[str, str], + Doc("Mutable headers dict — changes are sent with the request."), + ], + ) -> None: + self.method = method + self._url = url + self.route = route + self.app = app + self.state = SimpleNamespace() + self.headers = headers + + @property + def url(self) -> httpx.URL: + """Structured URL — gives ``.host``, ``.port``, ``.scheme``, ``.path``, ``.params``.""" + return httpx.URL(self._url) + + @property + def host(self) -> str | None: + """Shortcut for ``request.url.host``.""" + return self.url.host + + @property + def query_params(self) -> dict[str, Any]: + """Read-only snapshot of the query parameters that will be sent.""" + return self.route.params or {} + + @property + def json(self) -> dict[str, Any] | None: + """Read-only snapshot of the JSON body that will be sent, if any.""" + return self.route.json + + @property + def content(self) -> Any: # noqa: ANN401 + """Read-only snapshot of the raw body/form data that will be sent, if any.""" + return self.route.data + + def __repr__(self) -> str: + return f"" From debde096b557be741f2c1adb315d72a23dc17484 Mon Sep 17 00:00:00 2001 From: NEFORCEO Date: Sat, 25 Jul 2026 13:42:01 +0700 Subject: [PATCH 12/13] update types.py --- fasthttp/types.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/fasthttp/types.py b/fasthttp/types.py index b7d128e..b03cf55 100644 --- a/fasthttp/types.py +++ b/fasthttp/types.py @@ -2,14 +2,28 @@ import io from os import PathLike -from typing import Annotated, Literal, TypeAlias, TypedDict +from typing import TYPE_CHECKING, Annotated, Any, Literal, TypeAlias, TypedDict from annotated_doc import Doc +if TYPE_CHECKING: + from collections.abc import Callable, Coroutine + + from .request import Request + from .response import Response + HTTPMethod: TypeAlias = Literal[ "GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS", "QUERY" ] +CallNext: TypeAlias = "Callable[[Request], Coroutine[Any, Any, Response | None]]" +"""Signature of the ``call_next`` callable passed to ``@app.middleware(\"http\")`` handlers.""" + +HTTPMiddlewareFunc: TypeAlias = ( + "Callable[[Request, CallNext], Coroutine[Any, Any, Response | None]]" +) +"""Signature of a function registered via ``@app.middleware(\"http\")``.""" + OAuth2Scope: TypeAlias = Literal[ "openid", "profile", From 6bcf0d06b07d2e17fbaadbe045d659973e137853 Mon Sep 17 00:00:00 2001 From: NEFORCEO Date: Sat, 25 Jul 2026 13:42:04 +0700 Subject: [PATCH 13/13] test: update test_app.py --- tests/test_app.py | 123 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 123 insertions(+) diff --git a/tests/test_app.py b/tests/test_app.py index 1c87bb4..7238dd9 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -6,6 +6,7 @@ import pytest from fasthttp import FastHTTP +from fasthttp.request import Request from fasthttp.response import Response from fasthttp.routing import Route, Router @@ -603,3 +604,125 @@ async def handle_value_error(route, exc): return None assert app.event_hooks.get_exception_handler(KeyError("x")) is None + + +class TestFastHTTPMiddlewareDecorator: + """Tests for the @app.middleware("http") decorator.""" + + @staticmethod + def _mock_httpx_response(status_code: int = 200) -> MagicMock: + import datetime + + response = MagicMock() + response.status_code = status_code + response.text = '{"ok": true}' + response.headers = {"Content-Type": "application/json"} + response.content = b'{"ok": true}' + response.history = [] + response.elapsed = datetime.timedelta(seconds=0.01) + response.http_version = "HTTP/1.1" + response.reason_phrase = "OK" + return response + + def test_middleware_rejects_unsupported_type(self) -> None: + app = FastHTTP() + + with pytest.raises(ValueError, match="'ws'"): + app.middleware("ws") + + def test_middleware_registers_function(self) -> None: + app = FastHTTP() + + @app.middleware("http") + async def mw(request: Request, call_next): + return await call_next(request) + + assert app._http_function_middleware == [mw] + + @pytest.mark.asyncio + async def test_middleware_mutates_headers_and_response(self) -> None: + app = FastHTTP(security=False) + + seen_headers: dict = {} + + @app.middleware("http") + async def add_trace_id(request: Request, call_next): + assert isinstance(request, Request) + assert request.method == "GET" + assert request.host == "example.com" + request.headers["X-Trace-Id"] = "trace-123" + response = await call_next(request) + if response is not None: + response.headers["X-Elapsed"] = "yes" + return response + + @app.get(url="https://example.com/api") + async def handler(resp: Response) -> dict: + return resp.json() + + mock_client = AsyncMock() + + async def fake_request(**kwargs: object) -> MagicMock: + seen_headers.update(kwargs.get("headers") or {}) + return self._mock_httpx_response() + + mock_client.request = fake_request + + route = app.routes[0] + _, _, result = await app._run_route(mock_client, route) + + assert "X-Trace-Id" in seen_headers + assert result is not None + assert result.headers["X-Elapsed"] == "yes" + + @pytest.mark.asyncio + async def test_middleware_order_outer_to_inner(self) -> None: + app = FastHTTP(security=False) + calls: list[str] = [] + + @app.middleware("http") + async def outer(request: Request, call_next): + calls.append("outer:before") + response = await call_next(request) + calls.append("outer:after") + return response + + @app.middleware("http") + async def inner(request: Request, call_next): + calls.append("inner:before") + response = await call_next(request) + calls.append("inner:after") + return response + + @app.get(url="https://example.com/api") + async def handler(resp: Response) -> dict: + return resp.json() + + mock_client = AsyncMock() + mock_client.request = AsyncMock(return_value=self._mock_httpx_response()) + + route = app.routes[0] + await app._run_route(mock_client, route) + + assert calls == ["outer:before", "inner:before", "inner:after", "outer:after"] + + @pytest.mark.asyncio + async def test_middleware_can_short_circuit(self) -> None: + app = FastHTTP(security=False) + + @app.middleware("http") + async def block(_request: Request, _call_next): + return None + + @app.get(url="https://example.com/api") + async def handler(resp: Response) -> dict: + return resp.json() + + mock_client = AsyncMock() + mock_client.request = AsyncMock(return_value=self._mock_httpx_response()) + + route = app.routes[0] + _, _, result = await app._run_route(mock_client, route) + + assert result is None + mock_client.request.assert_not_called()