diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6e34e93..cb8527f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -36,6 +36,19 @@ jobs: echo "skip=false" >> $GITHUB_OUTPUT fi + - name: Send Telegram announcement + if: steps.tag_check.outputs.skip == 'false' + run: | + TAG="v${{ env.version }}" + MESSAGE="⏳ *fasthttp-client ${TAG} coming soon...* + + Новая версия уже собирается и скоро появится на PyPI 📦" + + curl -s -X POST "https://api.telegram.org/bot${{ secrets.TELEGRAM_BOT_TOKEN }}/sendMessage" \ + -d "chat_id=${{ secrets.TELEGRAM_CHAT_ID }}" \ + --data-urlencode "text=${MESSAGE}" \ + -d "parse_mode=Markdown" + - name: Get current commit SHA if: steps.tag_check.outputs.skip == 'false' id: commit @@ -93,14 +106,11 @@ jobs: if: steps.tag_check.outputs.skip == 'false' run: | TAG="v${{ env.version }}" - MESSAGE="🚀 *Release Published!* - - 📦 *Package:* fasthttp-client ${TAG} + MESSAGE="🚀 *fasthttp-client ${TAG} released* - 📝 *Description:* Fast and simple HTTP client library with async support and beautiful logging + ⚡ Async HTTP client with middleware, routing & OpenAPI support - 👤 *Author:* ${{ github.actor }} - 🕐 *Time:* ${{ github.event.head_commit.timestamp }}" + 👤 ${{ github.actor }} · 🕐 ${{ github.event.head_commit.timestamp }}" KEYBOARD='{"inline_keyboard":[[{"text":"📦 PyPI","url":"https://pypi.org/project/fasthttp-client/"},{"text":"📖 Docs","url":"https://fasthttp.ndugram.dev"},{"text":"💻 GitHub","url":"https://github.com/'${GITHUB_REPOSITORY}'/releases/tag/'${TAG}'"}]]}' diff --git a/docs/en/middleware.md b/docs/en/middleware.md index dec623f..c1c16d0 100644 --- a/docs/en/middleware.md +++ b/docs/en/middleware.md @@ -1,16 +1,23 @@ # Middleware -Middleware allows adding global logic that will be executed for all requests. +Middleware lets you intercept and modify every request and response through `FastHTTP` — without changing handler code. -## Introduction +## What middleware can do -Middleware in FastHTTP works similarly to middleware in FastAPI, but is designed for outgoing requests. It allows: +- Automatically add authorization headers +- Log all requests and responses +- Add timing and tracing headers +- Retry requests on specific response codes +- Transform response data -- Modifying requests before sending -- Modifying responses after receiving -- Handling errors -- Adding logging -- Adding authentication +## How it works + +``` +request → mw1.request → mw2.request → mw3.request → [HTTP] +response ← mw1.response ← mw2.response ← mw3.response ← [HTTP] +``` + +Middleware executes in `__priority__` order on the way in and in **reverse order** on the way out. ## Creating Middleware @@ -23,44 +30,44 @@ from fasthttp.response import Response class MyMiddleware(BaseMiddleware): - async def before_request(self, route, config): - """Executed before each request.""" - # Modify config - config.setdefault("headers", {})["X-Custom"] = "value" - return config - - async def after_response(self, response, route, config): - """Executed after each response.""" - # Modify response + __return_type__ = None + __priority__ = 0 + __methods__ = None + __enabled__ = True + + async def request(self, method, url, kwargs): + kwargs["headers"] = kwargs.get("headers") or {} + kwargs["headers"]["X-Custom"] = "value" + return kwargs + + async def response(self, response): return response async def on_error(self, error, route, config): - """Executed on error.""" print(f"Error: {error}") - raise error ``` -## Using Middleware +## Attaching to the app -```python -from fasthttp import FastHTTP +=== "List" -app = FastHTTP(middleware=MyMiddleware()) -``` + ```python + app = FastHTTP(middleware=[AuthMiddleware(), LoggingMiddleware()]) + ``` -### Multiple Middleware +=== "Pipe" -Execution order — first added executes first: + ```python + app = FastHTTP(middleware=AuthMiddleware() | LoggingMiddleware()) + ``` -```python -app = FastHTTP(middleware=[ - AuthMiddleware(), - LoggingMiddleware(), - MetricsMiddleware(), -]) -``` +=== "Single" + + ```python + app = FastHTTP(middleware=MyMiddleware()) + ``` -## Middleware Examples +## Examples ### Authentication @@ -70,14 +77,18 @@ from fasthttp.middleware import BaseMiddleware class AuthMiddleware(BaseMiddleware): + __return_type__ = bool + __priority__ = 0 + __methods__ = None + __enabled__ = True + def __init__(self, token: str): self.token = token - async def before_request(self, route, config): - headers = config.get("headers", {}) - headers["Authorization"] = f"Bearer {self.token}" - config["headers"] = headers - return config + async def request(self, method, url, kwargs): + kwargs["headers"] = kwargs.get("headers") or {} + kwargs["headers"]["Authorization"] = f"Bearer {self.token}" + return kwargs app = FastHTTP(middleware=[AuthMiddleware(token="your-token")]) @@ -92,12 +103,15 @@ from fasthttp.middleware import BaseMiddleware class TraceMiddleware(BaseMiddleware): - async def before_request(self, route, config): - trace_id = str(uuid.uuid4()) - headers = config.get("headers", {}) - headers["X-Trace-ID"] = trace_id - config["headers"] = headers - return config + __return_type__ = None + __priority__ = 0 + __methods__ = None + __enabled__ = True + + async def request(self, method, url, kwargs): + kwargs["headers"] = kwargs.get("headers") or {} + kwargs["headers"]["X-Trace-ID"] = str(uuid.uuid4()) + return kwargs app = FastHTTP(middleware=[TraceMiddleware()]) @@ -107,20 +121,28 @@ app = FastHTTP(middleware=[TraceMiddleware()]) ```python import time +from contextvars import ContextVar from fasthttp import FastHTTP from fasthttp.middleware import BaseMiddleware class LoggingMiddleware(BaseMiddleware): - async def before_request(self, route, config): - print(f"🚀 Sending: {route.method} {route.url}") - config["_start_time"] = time.time() - return config - - async def after_response(self, response, route, config): - start_time = config.get("_start_time", 0) - duration = time.time() - start_time - print(f"✅ Response: {route.method} {route.url} - {response.status} ({duration:.2f}s)") + __return_type__ = None + __priority__ = 99 + __methods__ = None + __enabled__ = True + + def __init__(self) -> None: + self._start: ContextVar[float] = ContextVar("log_start", default=0.0) + + async def request(self, method, url, kwargs): + print(f"→ {method} {url}") + self._start.set(time.monotonic()) + return kwargs + + async def response(self, response): + elapsed = time.monotonic() - self._start.get() + print(f"← {response.status} ({elapsed:.2f}s)") return response @@ -129,55 +151,19 @@ app = FastHTTP(middleware=[LoggingMiddleware()]) ### Caching -FastHTTP comes with built-in CacheMiddleware: +FastHTTP comes with built-in `CacheMiddleware`: ```python from fasthttp import FastHTTP, CacheMiddleware -app = FastHTTP(middleware=[ - CacheMiddleware(ttl=3600, max_size=100) # TTL in seconds, max cache size -]) +app = FastHTTP( + middleware=[CacheMiddleware(ttl=3600, max_size=100)] +) ``` -Caches GET requests in memory. +Caches GET requests in memory with LRU eviction. -### Rate Limiting - -```python -import time -from collections import defaultdict -from fasthttp import FastHTTP -from fasthttp.middleware import BaseMiddleware - - -class RateLimitMiddleware(BaseMiddleware): - def __init__(self, max_requests: int = 10, window: int = 60): - self.max_requests = max_requests - self.window = window - self.requests = defaultdict(list) - - async def before_request(self, route, config): - now = time.time() - host = config.get("headers", {}).get("Host", "default") - - # Clean old requests - self.requests[host] = [ - t for t in self.requests[host] - if now - t < self.window - ] - - # Check limit - if len(self.requests[host]) >= self.max_requests: - raise Exception(f"Rate limit exceeded: {self.max_requests} requests per {self.window}s") - - self.requests[host].append(now) - return config - - -app = FastHTTP(middleware=[RateLimitMiddleware(max_requests=10, window=60)]) -``` - -### Response Modification +### Response modification ```python from fasthttp import FastHTTP @@ -185,8 +171,12 @@ from fasthttp.middleware import BaseMiddleware class ResponseModifierMiddleware(BaseMiddleware): - async def after_response(self, response, route, config): - # Add headers to response + __return_type__ = None + __priority__ = 0 + __methods__ = None + __enabled__ = True + + async def response(self, response): response.headers["X-Custom-Response"] = "value" return response @@ -194,60 +184,38 @@ class ResponseModifierMiddleware(BaseMiddleware): app = FastHTTP(middleware=[ResponseModifierMiddleware()]) ``` -## Middleware Lifecycle - -``` -before_request → [Send Request] → after_response - or - on_error -``` - -### before_request(route, config) +## Class attributes -Called before sending each request. Can modify `config`. +| Attribute | Type | Description | +|-----------|------|-------------| +| `__return_type__` | `type \| None` | Type this middleware operates on | +| `__priority__` | `int` | Execution order — **lower runs first** | +| `__methods__` | `list[str] \| None` | HTTP methods to intercept. `None` = all methods | +| `__enabled__` | `bool` | `False` skips without removing from chain | -**Parameters:** -- `route` — route information -- `config` — request configuration +## Runtime toggle -**Returns:** modified `config` - -### after_response(response, route, config) - -Called after receiving a response. Can modify `response`. - -**Parameters:** -- `response` — response object -- `route` — route information -- `config` — request configuration - -**Returns:** modified `response` - -### on_error(error, route, config) - -Called when an error occurs. - -**Parameters:** -- `error` — exception -- `route` — route information -- `config` — request configuration +```python +debug = LoggingMiddleware() +app = FastHTTP(middleware=[debug]) -**Can:** -- Handle error and return a value -- Re-raise the error +debug.__enabled__ = False # disable +debug.__enabled__ = True # re-enable +``` ## Comparison with Dependencies | Feature | Middleware | Dependencies | |---------|------------|--------------| | Global application | ✅ Yes | ❌ No | -| Application to specific request | ❌ No | ✅ Yes | +| Specific request | ❌ No | ✅ Yes | | Response modification | ✅ Yes | ❌ No | | Error handling | ✅ Yes | ❌ No | | Complexity | Higher | Lower | -## See Also +## See also +- [Creating Middleware](tutorial/middleware/creating.md) — full API, pipe chaining +- [Middleware Examples](tutorial/middleware/examples.md) — ready-made recipes +- [Middleware Reference](reference/middleware.md) — class documentation - [Dependencies](dependencies.md) — for specific requests -- [Configuration](configuration.md) — settings -- [Quick Start](quick-start.md) — basics diff --git a/docs/en/reference/middleware.md b/docs/en/reference/middleware.md index 952f4b8..69f9a74 100644 --- a/docs/en/reference/middleware.md +++ b/docs/en/reference/middleware.md @@ -1,82 +1,147 @@ # Middleware Reference -Middleware classes reference. - ## BaseMiddleware -Base class for creating middleware: +Base class for all middleware. Subclass and override `request` and/or `response`. ```python from fasthttp.middleware import BaseMiddleware - class MyMiddleware(BaseMiddleware): - async def before_request(self, route, config): - return config - - async def after_response(self, response, route, config): + __return_type__ = None + __priority__ = 0 + __methods__ = None + __enabled__ = True + + async def request(self, method, url, kwargs): + return kwargs + + async def response(self, response): return response - + async def on_error(self, error, route, config): - raise error + pass ``` -## Methods +### Class attributes + +| Attribute | Type | Description | +|-----------|------|-------------| +| `__return_type__` | `type \| None` | Type this middleware operates on | +| `__priority__` | `int` | Execution order (lower = earlier) | +| `__methods__` | `list[str] \| None` | HTTP methods to apply to. `None` = all | +| `__enabled__` | `bool` | `False` skips without removing from chain | + +### Methods + +#### `request(method, url, kwargs) → dict` + +Called before the request is sent. -### before_request(route, config) +| Parameter | Type | Description | +|-----------|------|-------------| +| `method` | `str` | HTTP method (`"GET"`, `"POST"`, etc.) | +| `url` | `str` | Resolved URL | +| `kwargs` | `dict` | Request arguments: `headers`, `params`, `json`, `data`, `timeout` | -Called before each request. +**Returns:** modified `kwargs`. -**Parameters:** -- `route` - Route information -- `config` - Request configuration +#### `response(response) → Response` -**Returns:** Modified config +Called after the response is received. Called in **reverse** priority order. -### after_response(response, route, config) +| Parameter | Type | Description | +|-----------|------|-------------| +| `response` | `Response` | Response object | -Called after each response. +**Returns:** modified `Response`. -**Parameters:** -- `response` - Response object -- `route` - Route information -- `config` - Request configuration +#### `on_error(error, route, config) → None` -**Returns:** Modified response +Called on request error. -### on_error(error, route, config) +| Parameter | Type | Description | +|-----------|------|-------------| +| `error` | `Exception` | Exception | +| `route` | `Route` | Route information | +| `config` | `dict` | Request configuration | -Called on error. +--- -**Parameters:** -- `error` - Exception -- `route` - Route information -- `config` - Request configuration +## MiddlewareChain -**Can:** Handle or re-raise error +Ordered chain of middleware instances, created via the `|` operator. + +```python +from fasthttp.middleware import MiddlewareChain + +chain = AuthMiddleware() | LoggingMiddleware() | TimingMiddleware() +``` + +Passed directly to `FastHTTP`: + +```python +app = FastHTTP(middleware=chain) +``` + +### Methods + +| Method | Description | +|--------|-------------| +| `__or__(other)` | Appends middleware to end of chain | +| `__iter__()` | Iterate over middleware | +| `__len__()` | Number of middleware in chain | +| `__repr__()` | String representation | + +--- + +## MiddlewareManager + +Internal manager that drives chain execution. Accepts `list`, `MiddlewareChain`, or `None`. + +```python +from fasthttp.middleware import MiddlewareManager + +manager = MiddlewareManager([AuthMiddleware(), LoggingMiddleware()]) +``` + +Methods are called automatically by `HTTPClient`. + +--- ## CacheMiddleware -Built-in caching middleware: +Built-in middleware for caching responses in memory. ```python -from fasthttp import CacheMiddleware +from fasthttp import FastHTTP, CacheMiddleware app = FastHTTP( middleware=[CacheMiddleware(ttl=3600, max_size=100)] ) ``` -**Parameters:** -- `ttl` - Cache time-to-live (seconds) -- `max_size` - Maximum cached requests +### Constructor parameters -## MiddlewareManager +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `ttl` | `int` | `3600` | Cache time-to-live in seconds | +| `max_size` | `int` | `100` | Maximum number of entries (LRU eviction) | +| `cache_methods` | `list[str]` | `["GET"]` | HTTP methods to cache | -Manages middleware execution: +### Methods + +| Method | Description | +|--------|-------------| +| `clear()` | Clears all cached responses | +| `get_stats()` | Returns cache statistics | ```python -from fasthttp.middleware import MiddlewareManager +cache = CacheMiddleware(ttl=60) +app = FastHTTP(middleware=[cache]) -manager = MiddlewareManager([middleware1, middleware2]) +# later +cache.clear() +print(cache.get_stats()) +# {'size': 0, 'max_size': 100, 'ttl': 60, 'methods': ['GET']} ``` diff --git a/docs/en/tutorial/middleware/creating.md b/docs/en/tutorial/middleware/creating.md index 0810a19..3f2afa6 100644 --- a/docs/en/tutorial/middleware/creating.md +++ b/docs/en/tutorial/middleware/creating.md @@ -1,97 +1,156 @@ # Creating Middleware -Learn how to create custom middleware. +## BaseMiddleware -## Base Class - -Create a class inheriting from `BaseMiddleware`: +All middleware inherits from `BaseMiddleware`: ```python -from fasthttp import FastHTTP from fasthttp.middleware import BaseMiddleware -from fasthttp.response import Response - class MyMiddleware(BaseMiddleware): - async def before_request(self, route, config): - """Executed before each request.""" - return config + __return_type__ = bool + __priority__ = 0 + __methods__ = None + __enabled__ = True - async def after_response(self, response, route, config): - """Executed after each response.""" + async def request(self, method, url, kwargs): + # modify kwargs before the request is sent + return kwargs + + async def response(self, response): + # inspect or modify the response return response async def on_error(self, error, route, config): - """Executed on error.""" - raise error + # handle the error + pass ``` -## Method Signatures +## Class attributes + +| Attribute | Type | Description | +|-----------|------|-------------| +| `__return_type__` | `type \| None` | Type this middleware operates on | +| `__priority__` | `int` | Execution order — **lower runs first** | +| `__methods__` | `list[str] \| None` | HTTP methods to intercept. `None` = all methods | +| `__enabled__` | `bool` | `False` skips middleware without removing it from the chain | + +!!! note "No defaults" + None of these attributes have defaults in `BaseMiddleware`. Define + only the ones you need. + +## `request(method, url, kwargs)` + +Called **before** the HTTP request is sent. Receives: -### before_request(route, config) +- `method` — HTTP method (`"GET"`, `"POST"`, etc.) +- `url` — resolved URL (scheme already prepended) +- `kwargs` — dict with keys: `params`, `headers`, `json`, `data`, `timeout` -Called before sending each request. +Must return the (possibly modified) `kwargs` dict: -**Parameters:** -- `route` - Route information -- `config` - Request configuration +```python +async def request(self, method, url, kwargs): + kwargs["headers"] = kwargs.get("headers") or {} + kwargs["headers"]["X-Request-ID"] = "some-id" + return kwargs +``` -**Returns:** Modified `config` +!!! warning "headers may be None" + `kwargs["headers"]` is `None` when no headers were passed. + Always use `kwargs.get("headers") or {}` before adding keys. -### after_response(response, route, config) +## `response(response)` -Called after receiving a response. +Called **after** the HTTP response is received. Receives a `Response` object. +Must return `Response`: -**Parameters:** -- `response` - Response object -- `route` - Route information -- `config` - Request configuration +```python +async def response(self, response): + if response.status >= 400: + print(f"Error {response.status}") + return response +``` -**Returns:** Modified `response` +## `on_error(error, route, config)` -### on_error(error, route, config) +Called on **request error**. Receives: -Called when an error occurs. +- `error` — exception +- `route` — route information +- `config` — request configuration -**Parameters:** -- `error` - Exception -- `route` - Route information -- `config` - Request configuration +```python +async def on_error(self, error, route, config): + print(f"Error: {route.method} {route.url} — {error}") +``` -**Can:** Handle error or re-raise +## Dunder methods -## Using Middleware +### `__repr__` ```python -app = FastHTTP(middleware=[MyMiddleware()]) +mw = MyMiddleware() +print(mw) # > ``` -## Multiple Middleware +### `__or__` — pipe chaining + +Combine middleware via `|`: -Execution order - first added executes first: +```python +chain = AuthMiddleware() | LoggingMiddleware() | TimingMiddleware() +``` + +Result is a `MiddlewareChain`, passed directly to `FastHTTP`: ```python -app = FastHTTP(middleware=[ - AuthMiddleware(), - LoggingMiddleware(), - MetricsMiddleware(), -]) +app = FastHTTP(middleware=chain) ``` -## Route Object Attributes +### `__init_subclass__` + +Fires on subclassing `BaseMiddleware`. Override for custom subclass validation: ```python -route.method # HTTP method -route.url # Request URL -route.params # Query parameters -route.json # JSON body -route.tags # Tags +class StrictMiddleware(BaseMiddleware): + def __init_subclass__(cls, **kwargs): + super().__init_subclass__(**kwargs) + if not hasattr(cls, "__priority__"): + raise TypeError(f"{cls.__name__} must define __priority__") ``` -## Config Object Keys +## Attaching to the app + +=== "List" + + ```python + FastHTTP(middleware=[AuthMiddleware(), LoggingMiddleware()]) + ``` + +=== "Pipe" + + ```python + FastHTTP(middleware=AuthMiddleware() | LoggingMiddleware()) + ``` + +=== "Single" + + ```python + FastHTTP(middleware=AuthMiddleware()) + ``` + +All three forms are equivalent. Sort order is determined by `__priority__` automatically. + +## Runtime toggle ```python -config.get("headers", {}) # Request headers -config.get("timeout", 30.0) # Timeout -config.get("allow_redirects", True) +debug = LoggingMiddleware() +app = FastHTTP(middleware=[debug]) + +# disable without removing from chain +debug.__enabled__ = False + +# re-enable +debug.__enabled__ = True ``` diff --git a/docs/en/tutorial/middleware/examples.md b/docs/en/tutorial/middleware/examples.md index b6d1e28..7e66bc9 100644 --- a/docs/en/tutorial/middleware/examples.md +++ b/docs/en/tutorial/middleware/examples.md @@ -1,134 +1,199 @@ # Middleware Examples -Practical middleware examples. +## Auth middleware -## Authentication Middleware +Adds a Bearer token to every request: ```python from fasthttp import FastHTTP from fasthttp.middleware import BaseMiddleware -class AuthMiddleware(BaseMiddleware): - def __init__(self, token: str): +class BearerAuthMiddleware(BaseMiddleware): + __return_type__ = bool + __priority__ = 0 + __methods__ = None + __enabled__ = True + + def __init__(self, token: str) -> None: self.token = token - async def before_request(self, route, config): - headers = config.get("headers", {}) - headers["Authorization"] = f"Bearer {self.token}" - config["headers"] = headers - return config + async def request(self, method, url, kwargs): + kwargs["headers"] = kwargs.get("headers") or {} + kwargs["headers"]["Authorization"] = f"Bearer {self.token}" + return kwargs -app = FastHTTP(middleware=[AuthMiddleware(token="your-token")]) +app = FastHTTP(middleware=[BearerAuthMiddleware("my-secret-token")]) ``` -## Logging Middleware +## Logging middleware + +Prints every request and response: ```python -import time -from fasthttp import FastHTTP from fasthttp.middleware import BaseMiddleware class LoggingMiddleware(BaseMiddleware): - async def before_request(self, route, config): - print(f"Sending: {route.method} {route.url}") - config["_start_time"] = time.time() - return config - - async def after_response(self, response, route, config): - start_time = config.get("_start_time", 0) - duration = time.time() - start_time - print(f"Response: {route.method} {route.url} - {response.status} ({duration:.2f}s)") + __return_type__ = None + __priority__ = 99 + __methods__ = None + __enabled__ = True + + async def request(self, method, url, kwargs): + print(f"→ {method} {url}") + return kwargs + + async def response(self, response): + print(f"← {response.status}") return response +``` + +!!! tip + High `__priority__` — logging runs **last on the way in** (sees final kwargs) + and **first on the way out** (sees the raw response). + +## Timing middleware + +Measures request duration: +```python +import time +from contextvars import ContextVar +from fasthttp.middleware import BaseMiddleware + + +class TimingMiddleware(BaseMiddleware): + __return_type__ = float + __priority__ = 0 + __methods__ = None + __enabled__ = True -app = FastHTTP(middleware=[LoggingMiddleware()]) + def __init__(self) -> None: + self._start: ContextVar[float] = ContextVar("timing_start", default=0.0) + + async def request(self, method, url, kwargs): + self._start.set(time.monotonic()) + return kwargs + + async def response(self, response): + elapsed = time.monotonic() - self._start.get() + print(f"Request took {elapsed:.3f}s") + return response ``` -## Trace ID Middleware +## Trace ID middleware + +Adds a unique ID to every request: ```python import uuid -from fasthttp import FastHTTP from fasthttp.middleware import BaseMiddleware class TraceMiddleware(BaseMiddleware): - async def before_request(self, route, config): - trace_id = str(uuid.uuid4()) - headers = config.get("headers", {}) - headers["X-Trace-ID"] = trace_id - config["headers"] = headers - return config + __return_type__ = None + __priority__ = 0 + __methods__ = None + __enabled__ = True + + async def request(self, method, url, kwargs): + kwargs["headers"] = kwargs.get("headers") or {} + kwargs["headers"]["X-Trace-ID"] = str(uuid.uuid4()) + return kwargs +``` + +## Method filtering +Run middleware only for specific HTTP methods: -app = FastHTTP(middleware=[TraceMiddleware()]) +```python +class WriteOpMiddleware(BaseMiddleware): + __return_type__ = bool + __priority__ = 1 + __methods__ = ["POST", "PUT", "PATCH", "DELETE"] + __enabled__ = True + + async def request(self, method, url, kwargs): + kwargs["headers"] = kwargs.get("headers") or {} + kwargs["headers"]["X-Write-Op"] = "true" + return kwargs ``` -## Rate Limiting Middleware +For `GET`, `HEAD`, and `OPTIONS` this middleware is silently skipped. + +## Toggle without removing + +Disable middleware at runtime without editing the app: ```python -import time -from collections import defaultdict -from fasthttp import FastHTTP -from fasthttp.middleware import BaseMiddleware +debug = LoggingMiddleware() +app = FastHTTP(middleware=[debug]) -class RateLimitMiddleware(BaseMiddleware): - def __init__(self, max_requests: int = 10, window: int = 60): - self.max_requests = max_requests - self.window = window - self.requests = defaultdict(list) - - async def before_request(self, route, config): - now = time.time() - host = config.get("headers", {}).get("Host", "default") - - # Clean old requests - self.requests[host] = [ - t for t in self.requests[host] - if now - t < self.window - ] - - # Check limit - if len(self.requests[host]) >= self.max_requests: - raise Exception(f"Rate limit: {self.max_requests} requests per {self.window}s") - - self.requests[host].append(now) - return config - - -app = FastHTTP(middleware=[RateLimitMiddleware(max_requests=10, window=60)]) +# disable at some point +debug.__enabled__ = False # not logged + +# re-enable +debug.__enabled__ = True # logged again ``` -## Response Modification Middleware +## Pipe chaining + +Combine multiple middleware in one line: ```python from fasthttp import FastHTTP +from fasthttp.middleware import MiddlewareChain + +chain = ( + BearerAuthMiddleware("token") + | TimingMiddleware() + | LoggingMiddleware() +) + +app = FastHTTP(middleware=chain) +``` + +Execution order on request: `BearerAuth → Timing → Logging → [HTTP]` +Execution order on response: `[HTTP] → Logging → Timing → BearerAuth` + +## Error tracking middleware + +Counts errors and logs context: + +```python from fasthttp.middleware import BaseMiddleware -class ResponseModifierMiddleware(BaseMiddleware): - async def after_response(self, response, route, config): - response.headers["X-Custom-Response"] = "value" - return response +class ErrorTrackingMiddleware(BaseMiddleware): + __return_type__ = None + __priority__ = 0 + __methods__ = None + __enabled__ = True + def __init__(self) -> None: + self.error_count = 0 -app = FastHTTP(middleware=[ResponseModifierMiddleware()]) + async def on_error(self, error, route, config) -> None: + self.error_count += 1 + print(f"Error #{self.error_count}: {error.__class__.__name__}") + print(f" {route.method} {route.url} — {error}") ``` -## Cache Middleware +## Caching FastHTTP includes built-in `CacheMiddleware`: ```python from fasthttp import FastHTTP, CacheMiddleware -app = FastHTTP(middleware=[ - CacheMiddleware(ttl=3600, max_size=100) # TTL in seconds, max cache size -]) +app = FastHTTP( + middleware=[CacheMiddleware(ttl=3600, max_size=100)] +) ``` -Caches GET requests in memory. +- `ttl` — cache time-to-live in seconds +- `max_size` — maximum number of entries (LRU eviction) +- `cache_methods` — list of methods to cache (default `["GET"]`) diff --git a/docs/en/tutorial/middleware/index.md b/docs/en/tutorial/middleware/index.md index 400268b..b27cbfd 100644 --- a/docs/en/tutorial/middleware/index.md +++ b/docs/en/tutorial/middleware/index.md @@ -1,13 +1,25 @@ # Middleware -Middleware allows adding global logic executed for all requests. +Middleware lets you intercept and modify **every request and response** through `FastHTTP` — without changing handler code. -## Overview +## What middleware can do -- [Creating Middleware](creating.md) - How to create custom middleware -- [Examples](examples.md) - Practical middleware examples +- Automatically add authorization headers +- Log all requests and responses +- Add timing and tracing headers +- Transform response data +- Track errors and metrics -## Quick Example +## How it works + +``` +request → mw1.request → mw2.request → mw3.request → [HTTP] +response ← mw1.response ← mw2.response ← mw3.response ← [HTTP] +``` + +Middleware executes in `__priority__` order on the way in and in **reverse order** on the way out. + +## Quick example ```python from fasthttp import FastHTTP @@ -15,29 +27,37 @@ from fasthttp.middleware import BaseMiddleware from fasthttp.response import Response -class MyMiddleware(BaseMiddleware): - async def before_request(self, route, config): - # Runs before each request - config.setdefault("headers", {})["X-Custom"] = "value" - return config +class LoggingMiddleware(BaseMiddleware): + __return_type__ = None + __priority__ = 0 + __methods__ = None + __enabled__ = True + + async def request(self, method, url, kwargs): + print(f"→ {method} {url}") + return kwargs - async def after_response(self, response, route, config): - # Runs after each response + async def response(self, response): + print(f"← {response.status}") return response -app = FastHTTP(middleware=[MyMiddleware()]) +app = FastHTTP(middleware=[LoggingMiddleware()]) ``` -## Lifecycle +Output: ``` -before_request -> [Send Request] -> after_response - or - on_error +→ GET https://httpbin.org/get +← 200 ``` -## Comparison with Dependencies +## Next steps + +- [Creating Middleware](creating.md) — BaseMiddleware API, class attributes, pipe chaining +- [Examples](examples.md) — auth, logging, timing, method filtering, toggle + +## Comparison with dependencies | Feature | Middleware | Dependencies | |---------|------------|--------------| @@ -45,4 +65,3 @@ before_request -> [Send Request] -> after_response | Specific request | No | Yes | | Can modify response | Yes | No | | Error handling | Yes | No | -| Simpler to use | No | Yes | diff --git a/docs/ru/middleware.md b/docs/ru/middleware.md index c63695f..c68624f 100644 --- a/docs/ru/middleware.md +++ b/docs/ru/middleware.md @@ -1,16 +1,23 @@ # Middleware -Middleware (промежуточное ПО) позволяет добавлять глобальную логику, которая будет выполняться для всех запросов. +Middleware позволяет перехватывать и модифицировать каждый запрос и ответ через `FastHTTP` — без изменения кода обработчиков. -## Введение +## Что может делать middleware -Middleware в FastHTTP работает похоже на middleware в FastAPI, но предназначено для исходящих запросов. Оно позволяет: +- Автоматически добавлять заголовки авторизации +- Логировать все запросы и ответы +- Добавлять заголовки таймингов и трейсинга +- Повторять запрос при определённых кодах ответа +- Трансформировать данные ответа -- Модифицировать запросы перед отправкой -- Модифицировать ответы после получения -- Обрабатывать ошибки -- Добавлять логирование -- Добавлять аутентификацию +## Как работает + +``` +запрос → mw1.request → mw2.request → mw3.request → [HTTP] +ответ ← mw1.response ← mw2.response ← mw3.response ← [HTTP] +``` + +Middleware выполняется в порядке `__priority__` на входе и в **обратном порядке** на выходе. ## Создание Middleware @@ -23,44 +30,44 @@ from fasthttp.response import Response class MyMiddleware(BaseMiddleware): - async def before_request(self, route, config): - """Выполняется перед каждым запросом.""" - # Модифицируем config - config.setdefault("headers", {})["X-Custom"] = "value" - return config - - async def after_response(self, response, route, config): - """Выполняется после каждого ответа.""" - # Модифицируем response + __return_type__ = None + __priority__ = 0 + __methods__ = None + __enabled__ = True + + async def request(self, method, url, kwargs): + kwargs["headers"] = kwargs.get("headers") or {} + kwargs["headers"]["X-Custom"] = "value" + return kwargs + + async def response(self, response): return response async def on_error(self, error, route, config): - """Выполняется при ошибке.""" print(f"Ошибка: {error}") - raise error ``` -## Использование Middleware +## Подключение -```python -from fasthttp import FastHTTP +=== "Список" -app = FastHTTP(middleware=MyMiddleware()) -``` + ```python + app = FastHTTP(middleware=[AuthMiddleware(), LoggingMiddleware()]) + ``` -### Несколько Middleware +=== "Pipe" -Порядок выполнения — первый добавленный выполняется первым: + ```python + app = FastHTTP(middleware=AuthMiddleware() | LoggingMiddleware()) + ``` -```python -app = FastHTTP(middleware=[ - AuthMiddleware(), - LoggingMiddleware(), - MetricsMiddleware(), -]) -``` +=== "Один" + + ```python + app = FastHTTP(middleware=MyMiddleware()) + ``` -## Примеры Middleware +## Примеры ### Аутентификация @@ -70,14 +77,18 @@ from fasthttp.middleware import BaseMiddleware class AuthMiddleware(BaseMiddleware): + __return_type__ = bool + __priority__ = 0 + __methods__ = None + __enabled__ = True + def __init__(self, token: str): self.token = token - async def before_request(self, route, config): - headers = config.get("headers", {}) - headers["Authorization"] = f"Bearer {self.token}" - config["headers"] = headers - return config + async def request(self, method, url, kwargs): + kwargs["headers"] = kwargs.get("headers") or {} + kwargs["headers"]["Authorization"] = f"Bearer {self.token}" + return kwargs app = FastHTTP(middleware=[AuthMiddleware(token="your-token")]) @@ -92,12 +103,15 @@ from fasthttp.middleware import BaseMiddleware class TraceMiddleware(BaseMiddleware): - async def before_request(self, route, config): - trace_id = str(uuid.uuid4()) - headers = config.get("headers", {}) - headers["X-Trace-ID"] = trace_id - config["headers"] = headers - return config + __return_type__ = None + __priority__ = 0 + __methods__ = None + __enabled__ = True + + async def request(self, method, url, kwargs): + kwargs["headers"] = kwargs.get("headers") or {} + kwargs["headers"]["X-Trace-ID"] = str(uuid.uuid4()) + return kwargs app = FastHTTP(middleware=[TraceMiddleware()]) @@ -107,20 +121,28 @@ app = FastHTTP(middleware=[TraceMiddleware()]) ```python import time +from contextvars import ContextVar from fasthttp import FastHTTP from fasthttp.middleware import BaseMiddleware class LoggingMiddleware(BaseMiddleware): - async def before_request(self, route, config): - print(f"🚀 Отправка: {route.method} {route.url}") - config["_start_time"] = time.time() - return config - - async def after_response(self, response, route, config): - start_time = config.get("_start_time", 0) - duration = time.time() - start_time - print(f"✅ Ответ: {route.method} {route.url} - {response.status} ({duration:.2f}s)") + __return_type__ = None + __priority__ = 99 + __methods__ = None + __enabled__ = True + + def __init__(self) -> None: + self._start: ContextVar[float] = ContextVar("log_start", default=0.0) + + async def request(self, method, url, kwargs): + print(f"→ {method} {url}") + self._start.set(time.monotonic()) + return kwargs + + async def response(self, response): + elapsed = time.monotonic() - self._start.get() + print(f"← {response.status} ({elapsed:.2f}s)") return response @@ -129,53 +151,17 @@ app = FastHTTP(middleware=[LoggingMiddleware()]) ### Кеширование -FastHTTP поставляется с встроенным CacheMiddleware: +FastHTTP поставляется с встроенным `CacheMiddleware`: ```python from fasthttp import FastHTTP, CacheMiddleware -app = FastHTTP(middleware=[ - CacheMiddleware(ttl=3600, max_size=100) # TTL в секундах, макс. размер кэша -]) +app = FastHTTP( + middleware=[CacheMiddleware(ttl=3600, max_size=100)] +) ``` -Кеширует GET запросы в памяти. - -### Rate Limiting - -```python -import time -from collections import defaultdict -from fasthttp import FastHTTP -from fasthttp.middleware import BaseMiddleware - - -class RateLimitMiddleware(BaseMiddleware): - def __init__(self, max_requests: int = 10, window: int = 60): - self.max_requests = max_requests - self.window = window - self.requests = defaultdict(list) - - async def before_request(self, route, config): - now = time.time() - host = config.get("headers", {}).get("Host", "default") - - # Очищаем старые запросы - self.requests[host] = [ - t for t in self.requests[host] - if now - t < self.window - ] - - # Проверяем лимит - if len(self.requests[host]) >= self.max_requests: - raise Exception(f"Rate limit exceeded: {self.max_requests} requests per {self.window}s") - - self.requests[host].append(now) - return config - - -app = FastHTTP(middleware=[RateLimitMiddleware(max_requests=10, window=60)]) -``` +Кэширует GET-запросы в памяти с LRU-вытеснением. ### Модификация ответа @@ -185,8 +171,12 @@ from fasthttp.middleware import BaseMiddleware class ResponseModifierMiddleware(BaseMiddleware): - async def after_response(self, response, route, config): - # Добавляем заголовки в ответ + __return_type__ = None + __priority__ = 0 + __methods__ = None + __enabled__ = True + + async def response(self, response): response.headers["X-Custom-Response"] = "value" return response @@ -194,47 +184,24 @@ class ResponseModifierMiddleware(BaseMiddleware): app = FastHTTP(middleware=[ResponseModifierMiddleware()]) ``` -## Жизненный цикл Middleware - -``` -before_request → [Отправка запроса] → after_response - или - on_error -``` - -### before_request(route, config) +## Атрибуты класса -Вызывается перед отправкой каждого запроса. Может модифицировать `config`. +| Атрибут | Тип | Описание | +|---------|-----|----------| +| `__return_type__` | `type \| None` | Тип, с которым работает middleware | +| `__priority__` | `int` | Порядок выполнения — **меньше = раньше** | +| `__methods__` | `list[str] \| None` | HTTP-методы для перехвата. `None` = все методы | +| `__enabled__` | `bool` | `False` пропускает без удаления из цепочки | -**Параметры:** -- `route` — информация о маршруте -- `config` — конфигурация запроса +## Toggle в рантайме -**Возвращает:** модифицированный `config` - -### after_response(response, route, config) - -Вызывается после получения ответа. Может модифицировать `response`. - -**Параметры:** -- `response` — объект ответа -- `route` — информация о маршруте -- `config` — конфигурация запроса - -**Возвращает:** модифицированный `response` - -### on_error(error, route, config) - -Вызывается при возникновении ошибки. - -**Параметры:** -- `error` — исключение -- `route` — информация о маршруте -- `config` — конфигурация запроса +```python +debug = LoggingMiddleware() +app = FastHTTP(middleware=[debug]) -**Может:** -- Обработать ошибку и вернуть значение -- Пробросить ошибку дальше +debug.__enabled__ = False # отключить +debug.__enabled__ = True # включить обратно +``` ## Сравнение с Dependencies @@ -248,6 +215,7 @@ before_request → [Отправка запроса] → after_response ## Смотрите также +- [Создание Middleware](tutorial/middleware/creating.md) — полный API, pipe-чейнинг +- [Примеры Middleware](tutorial/middleware/examples.md) — готовые рецепты +- [Справочник Middleware](reference/middleware.md) — документация по классам - [Зависимости](dependencies.md) — для конкретных запросов -- [Конфигурация](configuration.md) — настройки -- [Быстрый старт](quick-start.md) — основы diff --git a/docs/ru/reference/middleware.md b/docs/ru/reference/middleware.md index bf2068f..b2b8f96 100644 --- a/docs/ru/reference/middleware.md +++ b/docs/ru/reference/middleware.md @@ -1,49 +1,147 @@ # Справочник Middleware -Справочник по классам middleware. - ## BaseMiddleware -Базовый класс для создания middleware: +Базовый класс для всех middleware. Наследуйтесь и переопределяйте `request` и/или `response`. ```python from fasthttp.middleware import BaseMiddleware - class MyMiddleware(BaseMiddleware): - async def before_request(self, route, config): - return config - - async def after_response(self, response, route, config): + __return_type__ = None + __priority__ = 0 + __methods__ = None + __enabled__ = True + + async def request(self, method, url, kwargs): + return kwargs + + async def response(self, response): return response - + async def on_error(self, error, route, config): - raise error + pass ``` -## Методы +### Атрибуты класса + +| Атрибут | Тип | Описание | +|---------|-----|----------| +| `__return_type__` | `type \| None` | Тип, с которым работает middleware | +| `__priority__` | `int` | Порядок выполнения (меньше = раньше) | +| `__methods__` | `list[str] \| None` | HTTP-методы для применения. `None` = все | +| `__enabled__` | `bool` | `False` пропускает без удаления из цепочки | + +### Методы + +#### `request(method, url, kwargs) → dict` + +Вызывается до отправки запроса. + +| Параметр | Тип | Описание | +|----------|-----|----------| +| `method` | `str` | HTTP-метод (`"GET"`, `"POST"` и т.д.) | +| `url` | `str` | Разрешённый URL | +| `kwargs` | `dict` | Аргументы запроса: `headers`, `params`, `json`, `data`, `timeout` | + +**Возвращает:** модифицированный `kwargs`. -### before_request(route, config) +#### `response(response) → Response` -Вызывается перед каждым запросом. +Вызывается после получения ответа. Вызывается в **обратном** порядке приоритетов. -### after_response(response, route, config) +| Параметр | Тип | Описание | +|----------|-----|----------| +| `response` | `Response` | Объект ответа | -Вызывается после каждого ответа. +**Возвращает:** модифицированный `Response`. -### on_error(error, route, config) +#### `on_error(error, route, config) → None` -Вызывается при ошибке. +Вызывается при ошибке запроса. + +| Параметр | Тип | Описание | +|----------|-----|----------| +| `error` | `Exception` | Исключение | +| `route` | `Route` | Информация о маршруте | +| `config` | `dict` | Конфигурация запроса | + +--- + +## MiddlewareChain + +Упорядоченная цепочка middleware, создаётся через оператор `|`. + +```python +from fasthttp.middleware import MiddlewareChain + +chain = AuthMiddleware() | LoggingMiddleware() | TimingMiddleware() +``` + +Передаётся напрямую в `FastHTTP`: + +```python +app = FastHTTP(middleware=chain) +``` + +### Методы + +| Метод | Описание | +|-------|----------| +| `__or__(other)` | Добавляет middleware в конец цепочки | +| `__iter__()` | Итерация по middleware | +| `__len__()` | Количество middleware в цепочке | +| `__repr__()` | Строковое представление | + +--- + +## MiddlewareManager + +Внутренний менеджер, управляющий выполнением цепочки. Принимает `list`, `MiddlewareChain` или `None`. + +```python +from fasthttp.middleware import MiddlewareManager + +manager = MiddlewareManager([AuthMiddleware(), LoggingMiddleware()]) +``` + +Методы вызываются автоматически из `HTTPClient`. + +--- ## CacheMiddleware -Встроенный middleware для кеширования: +Встроенный middleware для кэширования ответов в памяти. ```python -from fasthttp import CacheMiddleware +from fasthttp import FastHTTP, CacheMiddleware -app = FastHTTP(middleware=[CacheMiddleware(ttl=3600, max_size=100)]) +app = FastHTTP( + middleware=[CacheMiddleware(ttl=3600, max_size=100)] +) ``` -- `ttl` - время жизни кэша (секунды) -- `max_size` - максимальное количество закэшированных запросов +### Параметры конструктора + +| Параметр | Тип | По умолчанию | Описание | +|----------|-----|--------------|----------| +| `ttl` | `int` | `3600` | Время жизни кэша в секундах | +| `max_size` | `int` | `100` | Максимальное число записей (LRU-вытеснение) | +| `cache_methods` | `list[str]` | `["GET"]` | HTTP-методы для кэширования | + +### Методы + +| Метод | Описание | +|-------|----------| +| `clear()` | Очищает весь кэш | +| `get_stats()` | Возвращает статистику кэша | + +```python +cache = CacheMiddleware(ttl=60) +app = FastHTTP(middleware=[cache]) + +# позже +cache.clear() +print(cache.get_stats()) +# {'size': 0, 'max_size': 100, 'ttl': 60, 'methods': ['GET']} +``` diff --git a/docs/ru/tutorial/middleware/creating.md b/docs/ru/tutorial/middleware/creating.md index 83986a4..74a51d4 100644 --- a/docs/ru/tutorial/middleware/creating.md +++ b/docs/ru/tutorial/middleware/creating.md @@ -1,43 +1,156 @@ # Создание Middleware -Узнайте, как создавать собственное middleware. +## BaseMiddleware -## Базовый класс - -Создайте класс, наследующий от `BaseMiddleware`: +Все middleware наследуются от `BaseMiddleware`: ```python from fasthttp.middleware import BaseMiddleware - class MyMiddleware(BaseMiddleware): - async def before_request(self, route, config): - # Выполняется перед каждым запросом - return config + __return_type__ = bool + __priority__ = 0 + __methods__ = None + __enabled__ = True - async def after_response(self, response, route, config): - # Выполняется после каждого ответа + async def request(self, method, url, kwargs): + # модифицируем kwargs до отправки запроса + return kwargs + + async def response(self, response): + # инспектируем или модифицируем ответ return response async def on_error(self, error, route, config): - # Выполняется при ошибке - raise error + # обрабатываем ошибку + pass ``` -## Использование +## Атрибуты класса + +| Атрибут | Тип | Описание | +|---------|-----|----------| +| `__return_type__` | `type \| None` | Тип, с которым работает middleware | +| `__priority__` | `int` | Порядок выполнения — **меньше = раньше** | +| `__methods__` | `list[str] \| None` | HTTP-методы для перехвата. `None` = все методы | +| `__enabled__` | `bool` | `False` пропускает middleware без удаления из цепочки | + +!!! note "Нет значений по умолчанию" + Ни один из этих атрибутов не имеет дефолта в `BaseMiddleware`. Определяйте + только те, которые нужны. + +## `request(method, url, kwargs)` + +Вызывается **до** отправки HTTP-запроса. Получает: + +- `method` — HTTP-метод (`"GET"`, `"POST"` и т.д.) +- `url` — разрешённый URL (схема уже добавлена) +- `kwargs` — dict с ключами: `params`, `headers`, `json`, `data`, `timeout` + +Должен вернуть (возможно изменённый) dict `kwargs`: ```python -app = FastHTTP(middleware=[MyMiddleware()]) +async def request(self, method, url, kwargs): + kwargs["headers"] = kwargs.get("headers") or {} + kwargs["headers"]["X-Request-ID"] = "some-id" + return kwargs ``` -## Несколько Middleware +!!! warning "headers может быть None" + `kwargs["headers"]` равен `None`, когда заголовки не передавались. + Всегда используйте `kwargs.get("headers") or {}` перед добавлением ключей. + +## `response(response)` -Порядок выполнения - первый добавленный выполняется первым: +Вызывается **после** получения HTTP-ответа. Получает объект `Response`. +Должен вернуть `Response`: ```python -app = FastHTTP(middleware=[ - AuthMiddleware(), - LoggingMiddleware(), - MetricsMiddleware(), -]) +async def response(self, response): + if response.status >= 400: + print(f"Ошибка {response.status}") + return response +``` + +## `on_error(error, route, config)` + +Вызывается при **ошибке запроса**. Получает: + +- `error` — исключение +- `route` — информация о маршруте +- `config` — конфигурация запроса + +```python +async def on_error(self, error, route, config): + print(f"Ошибка: {route.method} {route.url} — {error}") +``` + +## Dunder-методы + +### `__repr__` + +```python +mw = MyMiddleware() +print(mw) # > +``` + +### `__or__` — pipe-чейнинг + +Объединяйте middleware через `|`: + +```python +chain = AuthMiddleware() | LoggingMiddleware() | TimingMiddleware() +``` + +Результат — `MiddlewareChain`, передаётся прямо в `FastHTTP`: + +```python +app = FastHTTP(middleware=chain) +``` + +### `__init_subclass__` + +Срабатывает при наследовании от `BaseMiddleware`. Переопределите для валидации подклассов: + +```python +class StrictMiddleware(BaseMiddleware): + def __init_subclass__(cls, **kwargs): + super().__init_subclass__(**kwargs) + if not hasattr(cls, "__priority__"): + raise TypeError(f"{cls.__name__} должен определить __priority__") +``` + +## Подключение к приложению + +=== "Список" + + ```python + FastHTTP(middleware=[AuthMiddleware(), LoggingMiddleware()]) + ``` + +=== "Pipe" + + ```python + FastHTTP(middleware=AuthMiddleware() | LoggingMiddleware()) + ``` + +=== "Один" + + ```python + FastHTTP(middleware=AuthMiddleware()) + ``` + +Все три варианта эквивалентны. Порядок сортировки определяется `__priority__` автоматически. + +## Toggle в рантайме + +```python +debug = LoggingMiddleware() +app = FastHTTP(middleware=[debug]) + +# отключить без удаления из цепочки +debug.__enabled__ = False + +# включить обратно +debug.__enabled__ = True ``` diff --git a/docs/ru/tutorial/middleware/examples.md b/docs/ru/tutorial/middleware/examples.md index 4d65e28..5ff89a4 100644 --- a/docs/ru/tutorial/middleware/examples.md +++ b/docs/ru/tutorial/middleware/examples.md @@ -1,51 +1,91 @@ # Примеры Middleware -Практические примеры middleware. +## Auth middleware -## Аутентификация +Добавляет Bearer-токен в каждый запрос: ```python +from fasthttp import FastHTTP from fasthttp.middleware import BaseMiddleware -class AuthMiddleware(BaseMiddleware): - def __init__(self, token: str): +class BearerAuthMiddleware(BaseMiddleware): + __return_type__ = bool + __priority__ = 0 + __methods__ = None + __enabled__ = True + + def __init__(self, token: str) -> None: self.token = token - async def before_request(self, route, config): - headers = config.get("headers", {}) - headers["Authorization"] = f"Bearer {self.token}" - config["headers"] = headers - return config + async def request(self, method, url, kwargs): + kwargs["headers"] = kwargs.get("headers") or {} + kwargs["headers"]["Authorization"] = f"Bearer {self.token}" + return kwargs -app = FastHTTP(middleware=[AuthMiddleware(token="your-token")]) +app = FastHTTP(middleware=[BearerAuthMiddleware("my-secret-token")]) ``` -## Логирование +## Logging middleware + +Выводит каждый запрос и ответ: ```python -import time from fasthttp.middleware import BaseMiddleware class LoggingMiddleware(BaseMiddleware): - async def before_request(self, route, config): - print(f"Отправка: {route.method} {route.url}") - config["_start_time"] = time.time() - return config - - async def after_response(self, response, route, config): - start_time = config.get("_start_time", 0) - duration = time.time() - start_time - print(f"Ответ: {route.method} {route.url} - {response.status} ({duration:.2f}s)") + __return_type__ = None + __priority__ = 99 + __methods__ = None + __enabled__ = True + + async def request(self, method, url, kwargs): + print(f"→ {method} {url}") + return kwargs + + async def response(self, response): + print(f"← {response.status}") return response +``` + +!!! tip + Высокий `__priority__` — logging запускается **последним на входе** (видит финальные kwargs) + и **первым на выходе** (видит сырой ответ). + +## Timing middleware + +Измеряет продолжительность запроса: + +```python +import time +from contextvars import ContextVar +from fasthttp.middleware import BaseMiddleware -app = FastHTTP(middleware=[LoggingMiddleware()]) +class TimingMiddleware(BaseMiddleware): + __return_type__ = float + __priority__ = 0 + __methods__ = None + __enabled__ = True + + def __init__(self) -> None: + self._start: ContextVar[float] = ContextVar("timing_start", default=0.0) + + async def request(self, method, url, kwargs): + self._start.set(time.monotonic()) + return kwargs + + async def response(self, response): + elapsed = time.monotonic() - self._start.get() + print(f"Запрос занял {elapsed:.3f}s") + return response ``` -## Trace ID +## Trace ID middleware + +Добавляет уникальный ID к каждому запросу: ```python import uuid @@ -53,15 +93,93 @@ from fasthttp.middleware import BaseMiddleware class TraceMiddleware(BaseMiddleware): - async def before_request(self, route, config): - trace_id = str(uuid.uuid4()) - headers = config.get("headers", {}) - headers["X-Trace-ID"] = trace_id - config["headers"] = headers - return config + __return_type__ = None + __priority__ = 0 + __methods__ = None + __enabled__ = True + + async def request(self, method, url, kwargs): + kwargs["headers"] = kwargs.get("headers") or {} + kwargs["headers"]["X-Trace-ID"] = str(uuid.uuid4()) + return kwargs +``` +## Фильтр по методам -app = FastHTTP(middleware=[TraceMiddleware()]) +Запускает middleware только для определённых HTTP-методов: + +```python +class WriteOpMiddleware(BaseMiddleware): + __return_type__ = bool + __priority__ = 1 + __methods__ = ["POST", "PUT", "PATCH", "DELETE"] + __enabled__ = True + + async def request(self, method, url, kwargs): + kwargs["headers"] = kwargs.get("headers") or {} + kwargs["headers"]["X-Write-Op"] = "true" + return kwargs +``` + +Для `GET`, `HEAD` и `OPTIONS` этот middleware молча пропускается. + +## Toggle без удаления + +Отключайте middleware в рантайме без редактирования приложения: + +```python +debug = LoggingMiddleware() + +app = FastHTTP(middleware=[debug]) + +# в какой-то момент отключаем +debug.__enabled__ = False # не логируется + +# включаем обратно +debug.__enabled__ = True # снова логируется +``` + +## Цепочка через pipe + +Объединяйте несколько middleware в одну строку: + +```python +from fasthttp import FastHTTP +from fasthttp.middleware import MiddlewareChain + +chain = ( + BearerAuthMiddleware("token") + | TimingMiddleware() + | LoggingMiddleware() +) + +app = FastHTTP(middleware=chain) +``` + +Порядок выполнения на запросе: `BearerAuth → Timing → Logging → [HTTP]` +Порядок выполнения на ответе: `[HTTP] → Logging → Timing → BearerAuth` + +## Error tracking middleware + +Считает ошибки и логирует контекст: + +```python +from fasthttp.middleware import BaseMiddleware + + +class ErrorTrackingMiddleware(BaseMiddleware): + __return_type__ = None + __priority__ = 0 + __methods__ = None + __enabled__ = True + + def __init__(self) -> None: + self.error_count = 0 + + async def on_error(self, error, route, config) -> None: + self.error_count += 1 + print(f"Error #{self.error_count}: {error.__class__.__name__}") + print(f" {route.method} {route.url} — {error}") ``` ## Кеширование @@ -69,9 +187,13 @@ app = FastHTTP(middleware=[TraceMiddleware()]) FastHTTP включает встроенный `CacheMiddleware`: ```python -from fasthttp import CacheMiddleware +from fasthttp import FastHTTP, CacheMiddleware -app = FastHTTP(middleware=[ - CacheMiddleware(ttl=3600, max_size=100) -]) +app = FastHTTP( + middleware=[CacheMiddleware(ttl=3600, max_size=100)] +) ``` + +- `ttl` — время жизни кэша в секундах +- `max_size` — максимальное количество записей (LRU-вытеснение) +- `cache_methods` — список методов для кэширования (по умолчанию `["GET"]`) diff --git a/docs/ru/tutorial/middleware/index.md b/docs/ru/tutorial/middleware/index.md index 22f32c8..2c10545 100644 --- a/docs/ru/tutorial/middleware/index.md +++ b/docs/ru/tutorial/middleware/index.md @@ -1,39 +1,63 @@ # Middleware -Middleware позволяет добавлять глобальную логику для всех запросов. +Middleware позволяет перехватывать и модифицировать **каждый запрос и ответ** через `FastHTTP` — без изменения кода обработчиков. -## Обзор +## Что может делать middleware -- [Создание Middleware](creating.md) - Как создавать собственное middleware -- [Примеры](examples.md) - Практические примеры middleware +- Автоматически добавлять заголовки авторизации +- Логировать все запросы и ответы +- Добавлять заголовки таймингов и трейсинга +- Трансформировать данные ответа +- Отслеживать ошибки и метрики + +## Как работает + +``` +запрос → mw1.request → mw2.request → mw3.request → [HTTP] +ответ ← mw1.response ← mw2.response ← mw3.response ← [HTTP] +``` + +Middleware выполняется в порядке `__priority__` на входе и в **обратном порядке** на выходе. ## Быстрый пример ```python +import asyncio from fasthttp import FastHTTP from fasthttp.middleware import BaseMiddleware +from fasthttp.response import Response + +class LoggingMiddleware(BaseMiddleware): + __return_type__ = None + __priority__ = 0 + __methods__ = None + __enabled__ = True -class MyMiddleware(BaseMiddleware): - async def before_request(self, route, config): - config.setdefault("headers", {})["X-Custom"] = "value" - return config + async def request(self, method, url, kwargs): + print(f"→ {method} {url}") + return kwargs - async def after_response(self, response, route, config): + async def response(self, response): + print(f"← {response.status}") return response -app = FastHTTP(middleware=[MyMiddleware()]) +app = FastHTTP(middleware=[LoggingMiddleware()]) ``` -## Жизненный цикл +Вывод: ``` -before_request -> [Отправка запроса] -> after_response - или - on_error +→ GET https://httpbin.org/get +← 200 ``` +## Далее + +- [Создание Middleware](creating.md) — API BaseMiddleware, атрибуты класса, pipe-чейнинг +- [Примеры](examples.md) — auth, логирование, тайминги, фильтр по методам, toggle + ## Сравнение с зависимостями | Функция | Middleware | Зависимости | diff --git a/examples/middleware/basic_middleware.py b/examples/middleware/basic_middleware.py index 264ea14..c4e1b46 100644 --- a/examples/middleware/basic_middleware.py +++ b/examples/middleware/basic_middleware.py @@ -1,38 +1,38 @@ from fasthttp import FastHTTP from fasthttp.middleware import BaseMiddleware from fasthttp.response import Response -from fasthttp.routing import Route -from fasthttp.types import RequestsOptinal class LoggingMiddleware(BaseMiddleware): - async def before_request( - self, route: Route, config: RequestsOptinal - ) -> RequestsOptinal: - print(f"🚀 Sending {route.method} request to {route.url}") - return config - - async def after_response( - self, response: Response, route: Route, config: RequestsOptinal - ) -> Response: - print(f"✅ Received response with status {response.status}") + __return_type__ = None + __priority__ = 99 + __methods__ = ["GET"] + __enabled__ = True + + async def request(self, method: str, url: str, kwargs: dict) -> dict: + print(f"→ {method} {url}") + return kwargs + + async def response(self, response: Response) -> Response: + print(f"← {response.status}") return response class HeaderMiddleware(BaseMiddleware): - async def before_request( - self, route: Route, config: RequestsOptinal - ) -> RequestsOptinal: - headers = config.get("headers", {}) - headers["X-Custom-Header"] = "MyCustomValue" - headers["X-Request-ID"] = "12345" + __return_type__ = None + __priority__ = 0 + __methods__ = None + __enabled__ = True - config["headers"] = headers - print("📝 Added custom headers to request") - return config + async def request(self, method: str, url: str, kwargs: dict) -> dict: + kwargs["headers"] = kwargs.get("headers") or {} + kwargs["headers"]["X-Custom-Header"] = "MyCustomValue" + kwargs["headers"]["X-Request-ID"] = "12345" + print("Added custom headers to request") + return kwargs -app = FastHTTP(middleware=[LoggingMiddleware(), HeaderMiddleware()]) +app = FastHTTP(middleware=[HeaderMiddleware(), LoggingMiddleware()], debug=True) @app.get(url="https://httpbin.org/get") diff --git a/examples/middleware/error_middleware.py b/examples/middleware/error_middleware.py index 94140ba..de995c7 100644 --- a/examples/middleware/error_middleware.py +++ b/examples/middleware/error_middleware.py @@ -6,6 +6,11 @@ class ErrorTrackingMiddleware(BaseMiddleware): + __return_type__ = None + __priority__ = 0 + __methods__ = None + __enabled__ = True + def __init__(self) -> None: self.error_count = 0 @@ -13,21 +18,24 @@ async def on_error( self, error: Exception, route: Route, config: RequestsOptinal ) -> None: self.error_count += 1 - print(f"❌ Error #{self.error_count}: {error.__class__.__name__}") - print(f" Route: {route.method} {route.url}") - print(f" Message: {error!s}") + print(f"Error #{self.error_count}: {error.__class__.__name__}") + print(f" Route: {route.method} {route.url}") + print(f" Message: {error!s}") class RequestCounterMiddleware(BaseMiddleware): + __return_type__ = None + __priority__ = 1 + __methods__ = None + __enabled__ = True + def __init__(self) -> None: self.success_count = 0 - async def after_response( - self, response: Response, route: Route, config: RequestsOptinal - ) -> Response: + async def response(self, response: Response) -> Response: if response.status < 400: self.success_count += 1 - print(f"✅ Successful requests: {self.success_count}") + print(f"Successful requests: {self.success_count}") return response diff --git a/examples/middleware/response_transformer.py b/examples/middleware/response_transformer.py index a6cff2b..fc0a477 100644 --- a/examples/middleware/response_transformer.py +++ b/examples/middleware/response_transformer.py @@ -1,31 +1,40 @@ import json +from contextvars import ContextVar from fasthttp import FastHTTP from fasthttp.middleware import BaseMiddleware from fasthttp.response import Response -from fasthttp.routing import Route -from fasthttp.types import RequestsOptinal class ResponseTransformerMiddleware(BaseMiddleware): - async def after_response( - self, response: Response, route: Route, config: RequestsOptinal - ) -> Response: + __return_type__ = None + __priority__ = 0 + __methods__ = None + __enabled__ = True + + def __init__(self) -> None: + self._url: ContextVar[str] = ContextVar("transformer_url", default="") + + async def request(self, method: str, url: str, kwargs: dict) -> dict: + self._url.set(url) + return kwargs + + async def response(self, response: Response) -> Response: try: json_data = response.json() if isinstance(json_data, dict): json_data["_metadata"] = { "transformed_by": "ResponseTransformerMiddleware", - "original_url": route.url, + "original_url": self._url.get(), "status_code": response.status, } response.text = json.dumps(json_data) - print(f"🔄 Transformed response from {route.url}") + print(f"Transformed response from {self._url.get()}") except Exception as e: - print(f"⚠️ Could not transform response: {e}") + print(f"Could not transform response: {e}") return response diff --git a/fasthttp/__init__.py b/fasthttp/__init__.py index 78027f1..bd7bec3 100644 --- a/fasthttp/__init__.py +++ b/fasthttp/__init__.py @@ -2,7 +2,7 @@ from .__meta__ import __version__ from .app import FastHTTP from .dependencies import Depends -from .middleware import BaseMiddleware, CacheMiddleware, MiddlewareManager +from .middleware import BaseMiddleware, CacheMiddleware, MiddlewareChain, MiddlewareManager from .routing import Router __all__ = ( @@ -10,6 +10,7 @@ "CacheMiddleware", "Depends", "FastHTTP", + "MiddlewareChain", "MiddlewareManager", "Router", "__version__", diff --git a/fasthttp/app.py b/fasthttp/app.py index 6283d53..9559096 100644 --- a/fasthttp/app.py +++ b/fasthttp/app.py @@ -19,7 +19,7 @@ ) from .helpers.routing import apply_base_url, check_https_url from .logging import setup_logger -from .middleware import BaseMiddleware, MiddlewareManager +from .middleware import BaseMiddleware, MiddlewareChain, MiddlewareManager from .openapi.generator import generate_openapi_schema from .openapi.swagger import get_not_found_html, get_swagger_html from .openapi.urls import build_docs_urls @@ -330,7 +330,9 @@ async def lifespan(app: FastHTTP): if middleware is None: - normalized_middleware = [] + normalized_middleware: list[BaseMiddleware] | MiddlewareChain = [] + elif isinstance(middleware, MiddlewareChain): + normalized_middleware = middleware elif isinstance(middleware, list): normalized_middleware = middleware else: diff --git a/fasthttp/client.py b/fasthttp/client.py index 4b818aa..270ab7a 100644 --- a/fasthttp/client.py +++ b/fasthttp/client.py @@ -282,7 +282,7 @@ async def _execute_request( method=route.method, url=route.url, headers=config.get("headers"), - params=route.params, + params=config.get("params", route.params), json=route.json, content=route.data, timeout=timeout_config, diff --git a/fasthttp/middleware.py b/fasthttp/middleware.py index 8403c36..92ec8ef 100644 --- a/fasthttp/middleware.py +++ b/fasthttp/middleware.py @@ -1,13 +1,20 @@ +from __future__ import annotations + import asyncio import hashlib import json import time from collections import OrderedDict -from typing import TYPE_CHECKING, Annotated +from contextvars import ContextVar +from typing import TYPE_CHECKING, Annotated, Any, ClassVar from annotated_doc import Doc +from .types import HTTPMethod + if TYPE_CHECKING: + from collections.abc import Iterator + from .response import Response from .routing import Route from .types import RequestsOptinal @@ -17,440 +24,271 @@ class BaseMiddleware: """ Base class for middleware in FastHTTP. - Middleware allows you to hook into the request/response lifecycle - to add custom behavior such as logging, authentication, rate limiting, - request modification, response transformation, etc. + Override :meth:`request` and/or :meth:`response` to intercept requests + and responses. Override :meth:`on_error` to handle errors. - Override the methods you need in your middleware class: + Class attributes: - - `before_request()`: Called before sending the HTTP request - - `after_response()`: Called after receiving a successful response - - `on_error()`: Called when an error occurs during the request + - ``__return_type__``: expected type this middleware operates on. + - ``__priority__``: execution order — lower value runs first. + - ``__methods__``: HTTP methods to apply to (``None`` = all). + - ``__enabled__``: set to ``False`` to skip without removing from chain. Example: ```python from fasthttp.middleware import BaseMiddleware class LoggingMiddleware(BaseMiddleware): - async def before_request(self, route, config): - print(f"Requesting: {route.method} {route.url}") + __return_type__ = None + __priority__ = 0 + __methods__ = None + __enabled__ = True + + async def request(self, method, url, kwargs): + print(f"→ {method} {url}") + return kwargs - async def after_response(self, response, route, config): - print(f"Response: {response.status}") + async def response(self, response): + print(f"← {response.status}") + return response app = FastHTTP(middleware=[LoggingMiddleware()]) ``` """ - async def before_request( - self, - route: Annotated[ - "Route", - Doc( - """ - The route being executed. + __return_type__: ClassVar[type | None] + __priority__: ClassVar[int] + __methods__: ClassVar[list[HTTPMethod] | None] + __enabled__: ClassVar[bool] - Contains information about the HTTP method, - URL, handler function, and optional request data - such as query parameters, JSON body, or raw data. - """ - ), + def __repr__(self) -> str: + return_type = getattr(self, "__return_type__", None) + return f"<{self.__class__.__name__} return_type={return_type}>" + + def __or__(self, other: BaseMiddleware) -> MiddlewareChain: + """Combine two middleware into a :class:`MiddlewareChain` via ``|``.""" + return MiddlewareChain([self, other]) + + def __init_subclass__(cls, **kwargs: object) -> None: + super().__init_subclass__(**kwargs) + + async def request( + self, + method: Annotated[ + str, + Doc("HTTP method (GET, POST, etc.)."), ], - config: Annotated[ - "RequestsOptinal", + url: Annotated[ + str, + Doc("Resolved request URL."), + ], + kwargs: Annotated[ + dict[str, Any], Doc( """ - Request configuration dictionary. + Request keyword arguments (headers, params, json, data, timeout). - Contains optional settings for the HTTP request - including headers, timeout, and redirect behavior. - Modifications to this config will be applied to - the request before it is sent. + Modifications will be applied to the request before it is sent. + Always use ``kwargs.get('headers') or {}`` before adding header keys. """ ), ], ) -> Annotated[ - "RequestsOptinal", - Doc( - """ - Modified or original request configuration. - - Return the config dict (possibly modified) to apply - changes to the request. Any changes to headers, timeout, - or redirect settings will be used when sending the request. - """ - ), + dict[str, Any], + Doc("Modified or original kwargs dict passed to the next middleware or httpx."), ]: - """ - Called before sending the HTTP request. - - Use this hook to: - - Modify request headers - - Add authentication tokens - - Log outgoing requests - - Validate request parameters - - Apply rate limiting - - The config dict is mutable and changes will affect the request. - """ - return config + """Called before the HTTP request is sent.""" + return kwargs - async def after_response( + async def response( self, response: Annotated[ "Response", - Doc( - """ - The HTTP response object. - - Contains the response status code, text content, - headers, and methods for parsing JSON data. - """ - ), - ], - route: Annotated[ - "Route", - Doc( - """ - The route that was executed. - - Information about the request that generated this response, - including method, URL, handler, and request data. - """ - ), - ], - config: Annotated[ - "RequestsOptinal", - Doc( - """ - Request configuration that was used. - - The config dict that was applied to the request - before it was sent. Useful for logging or debugging. - """ - ), + Doc("Wrapped Response object."), ], ) -> Annotated[ "Response", - Doc( - """ - Modified or original response object. - - Return the response object (possibly modified) to apply - changes. You can modify the response text, add metadata, - or transform the response data in any way. - """ - ), + Doc("Modified or replaced response passed back up the chain."), ]: - """ - Called after receiving a successful response. - - Use this hook to: - - Transform response data - - Log response metrics - - Cache responses - - Validate response schema - - Extract custom headers - - The response object is mutable and changes will affect - how the response is processed by the handler. - """ + """Called after the HTTP response is received.""" return response async def on_error( self, error: Annotated[ Exception, - Doc( - """ - The exception that occurred. - - Can be any exception raised during request execution, - such as connection errors, timeout errors, or - HTTP status errors. - """ - ), + Doc("The exception that occurred."), ], route: Annotated[ "Route", - Doc( - """ - The route that failed. - - Information about the request that encountered an error, - including method, URL, handler, and request data. - """ - ), + Doc("The route that failed."), ], config: Annotated[ "RequestsOptinal", - Doc( - """ - Request configuration that was used. - - The config dict that was applied to the request - before the error occurred. Useful for error logging - and debugging. - """ - ), + Doc("Request configuration that was used."), ], ) -> Annotated[ None, - Doc( - """ - No return value. - - This method is called for side effects only, such as - logging, sending notifications, or tracking metrics. - """ - ), + Doc("No return value. Called for side effects only."), ]: - """ - Called when an error occurs during the request. + """Called when an error occurs during the request.""" + + +class MiddlewareChain: + """Ordered chain of :class:`BaseMiddleware` instances. + + Created via the ``|`` operator on middleware objects. + Passed directly to :class:`~fasthttp.app.FastHTTP` as ``middleware=``. + + Example: + ```python + chain = AuthMiddleware() | LoggingMiddleware() | TimingMiddleware() + app = FastHTTP(middleware=chain) + ``` + """ + + def __init__(self, middlewares: list[BaseMiddleware]) -> None: + self._middlewares = middlewares + + def __or__(self, other: BaseMiddleware) -> MiddlewareChain: + """Append another middleware to the chain via ``|``.""" + return MiddlewareChain([*self._middlewares, other]) - Use this hook to: - - Log errors with custom formatting - - Send error notifications - - Implement retry logic - - Track error metrics + def __repr__(self) -> str: + names = ", ".join(m.__class__.__name__ for m in self._middlewares) + return f"" - Note: This method cannot prevent the error from being logged - by the client. It is purely for additional error handling. - """ + def __iter__(self) -> Iterator[BaseMiddleware]: + return iter(self._middlewares) + + def __len__(self) -> int: + return len(self._middlewares) class MiddlewareManager: """ Manages execution of middleware chain. - This class is used internally by FastHTTP to execute middleware - in the order they were registered. + Accepts a list of :class:`BaseMiddleware` instances or a + :class:`MiddlewareChain`. Middleware is executed in ``__priority__`` + order on requests and in reverse order on responses. """ def __init__( self, middlewares: Annotated[ - list["BaseMiddleware"] | None, + list[BaseMiddleware] | MiddlewareChain | None, Doc( """ - List of middleware instances to execute. - - Middleware will be executed in the order they - appear in this list. Each middleware hook - (before_request, after_response, on_error) - will be called for all middleware in sequence. + Middleware to execute. Accepts a list, a MiddlewareChain + (built via ``|``), or None for no middleware. """ ), ] = None, - ) -> Annotated[ - None, - Doc( - """ - No return value. - - Initializes the middleware manager with the provided - middleware list or an empty list if None. - """ - ), - ]: - self.middlewares = middlewares or [] + ) -> None: + if isinstance(middlewares, MiddlewareChain): + self.middlewares: list[BaseMiddleware] = list(middlewares) + else: + self.middlewares = middlewares or [] + + def _sorted(self) -> list[BaseMiddleware]: + return sorted( + self.middlewares, + key=lambda m: getattr(m, "__priority__", 0), + ) + + def _active( + self, + middlewares: list[BaseMiddleware], + method: str, + ) -> list[BaseMiddleware]: + result = [] + for mw in middlewares: + if not getattr(mw, "__enabled__", True): + continue + allowed = getattr(mw, "__methods__", None) + if allowed is not None and method.upper() not in {m.upper() for m in allowed}: + continue + result.append(mw) + return result async def process_before_request( self, route: Annotated[ "Route", - Doc( - """ - The route being executed. - - Contains information about the HTTP request that - is about to be sent, including method, URL, - handler, and request data. - """ - ), + Doc("The route being executed."), ], config: Annotated[ "RequestsOptinal", - Doc( - """ - Initial request configuration. - - The config dict that will be passed through all - middleware before_request hooks. Each middleware - can modify this config. - """ - ), + Doc("Initial request configuration."), ], ) -> Annotated[ - "RequestsOptinal", - Doc( - """ - Final request configuration after middleware processing. - - Returns the config dict after all middleware - before_request hooks have been executed. - Modifications from middleware are applied. - """ - ), + dict[str, Any], + Doc("Final request configuration after middleware processing."), ]: - """ - Execute all before_request middleware hooks. - - This method iterates through all registered middleware - and calls their before_request method in order. - Each middleware can modify the config dict. - """ - current_config = config - for middleware in self.middlewares: - current_config = await middleware.before_request( - route, current_config - ) - return current_config + """Execute all request middleware hooks in priority order.""" + kwargs: dict[str, Any] = dict(config) + kwargs.setdefault("params", route.params) + + for mw in self._active(self._sorted(), route.method): + kwargs = await mw.request(route.method, route.url, kwargs) + + return kwargs async def process_after_response( self, response: Annotated[ "Response", - Doc( - """ - The HTTP response object. - - Contains the response that was received from - the server, including status, text, and headers. - """ - ), + Doc("The HTTP response object."), ], route: Annotated[ "Route", - Doc( - """ - The route that was executed. - - Information about the request that generated - this response, including method, URL, - handler, and request data. - """ - ), + Doc("The route that was executed."), ], config: Annotated[ "RequestsOptinal", - Doc( - """ - Request configuration that was used. - - The config dict that was applied to the request - before it was sent. - """ - ), + Doc("Request configuration that was used."), ], ) -> Annotated[ "Response", - Doc( - """ - Final response object after middleware processing. - - Returns the response after all middleware - after_response hooks have been executed. - Modifications from middleware are applied. - """ - ), + Doc("Final response after middleware processing."), ]: - """ - Execute all after_response middleware hooks. - - This method iterates through all registered middleware - and calls their after_response method in order. - Each middleware can modify the response object. - """ - current_response = response - for middleware in self.middlewares: - current_response = await middleware.after_response( - current_response, route, config - ) - return current_response + """Execute all response middleware hooks in reverse priority order.""" + current = response + for mw in reversed(self._active(self._sorted(), route.method)): + current = await mw.response(current) + return current async def process_on_error( self, error: Annotated[ Exception, - Doc( - """ - The exception that occurred. - - The error that was raised during request execution. - """ - ), + Doc("The exception that occurred."), ], route: Annotated[ "Route", - Doc( - """ - The route that failed. - - Information about the request that encountered - an error, including method, URL, handler, - and request data. - """ - ), + Doc("The route that failed."), ], config: Annotated[ "RequestsOptinal", - Doc( - """ - Request configuration that was used. - - The config dict that was applied to the request - before the error occurred. - """ - ), + Doc("Request configuration that was used."), ], ) -> Annotated[ None, - Doc( - """ - No return value. - - This method is called for side effects only, - such as error logging or notifications. - """ - ), + Doc("No return value."), ]: - """ - Execute all on_error middleware hooks. - - This method iterates through all registered middleware - and calls their on_error method in order. - Each middleware can handle the error as needed. - """ - for middleware in self.middlewares: - await middleware.on_error(error, route, config) + """Execute all on_error middleware hooks in priority order.""" + for mw in self._active(self._sorted(), route.method): + await mw.on_error(error, route, config) class CacheEntry: - """ - Represents a cached response entry. - - Internal class used by CacheMiddleware to store - cached responses with expiration time. - """ + """Cached response entry with expiration time.""" def __init__( self, - response: Annotated[ - "Response", - Doc("The HTTP response to cache."), - ], - ttl: Annotated[ - int, - Doc( - """ - Time to live in seconds. - - The cached response will expire after this - many seconds from creation. - """ - ), - ], + response: Annotated["Response", Doc("The HTTP response to cache.")], + ttl: Annotated[int, Doc("Time to live in seconds.")], ) -> None: self.response = response self.expires_at = time.time() + ttl @@ -467,7 +305,6 @@ class CacheMiddleware(BaseMiddleware): ```python from fasthttp import FastHTTP from fasthttp.middleware import CacheMiddleware - from fasthttp.response import Response app = FastHTTP( middleware=[CacheMiddleware(ttl=3600, max_size=100)] @@ -479,40 +316,24 @@ async def get_users(resp: Response): ``` """ + __return_type__ = None + __priority__ = 0 + __methods__ = None + __enabled__ = True + def __init__( self, ttl: Annotated[ int, - Doc( - """ - Time to live for cached responses in seconds. - - Default is 3600 (1 hour). After this time, - the cached response is considered expired. - """ - ), + Doc("Time to live for cached responses in seconds. Default 3600."), ] = 3600, max_size: Annotated[ int, - Doc( - """ - Maximum number of cached responses. - - When the cache reaches this size, the oldest - entry is evicted to make room for new ones. - """ - ), + Doc("Maximum number of cached responses. Oldest evicted when full."), ] = 100, cache_methods: Annotated[ list[str] | None, - Doc( - """ - HTTP methods to cache. - - Default is ["GET"] - only GET requests are cached. - Set to ["GET", "POST"] to cache POST responses as well. - """ - ), + Doc('HTTP methods to cache. Default ["GET"].'), ] = None, ) -> None: self.ttl = ttl @@ -520,107 +341,59 @@ def __init__( self.cache_methods = cache_methods or ["GET"] self._cache: OrderedDict[str, CacheEntry] = OrderedDict() self._lock = asyncio.Lock() + self._state: ContextVar[tuple[str | None, Any]] = ContextVar( + f"cache_state_{id(self)}", default=(None, None) + ) - def _generate_key(self, route: "Route") -> str: - key_data = f"{route.method}:{route.url}:{json.dumps(route.params or {}, sort_keys=True)}" + def _generate_key(self, method: str, url: str, params: Any) -> str: + key_data = f"{method}:{url}:{json.dumps(params or {}, sort_keys=True)}" return hashlib.md5(key_data.encode()).hexdigest() - async def before_request( - self, - route: Annotated[ - "Route", - Doc("The route being executed."), - ], - config: Annotated[ - "RequestsOptinal", - Doc("Request configuration."), - ], - ) -> Annotated[ - "RequestsOptinal", - Doc("Modified or original request configuration."), - ]: - if route.method not in self.cache_methods: - return config + async def request(self, method: str, url: str, kwargs: dict[str, Any]) -> dict[str, Any]: + if method not in self.cache_methods: + self._state.set((None, None)) + return kwargs - key = self._generate_key(route) + key = self._generate_key(method, url, kwargs.get("params")) async with self._lock: if key in self._cache: entry = self._cache[key] - if time.time() < entry.expires_at: self._cache.move_to_end(key) - config["_cache_hit"] = entry.response - return config + self._state.set((key, entry.response)) + return kwargs del self._cache[key] - return config + self._state.set((key, None)) + return kwargs - async def after_response( - self, - response: Annotated[ - "Response", - Doc("The HTTP response object."), - ], - route: Annotated[ - "Route", - Doc("The route that was executed."), - ], - config: Annotated[ - "RequestsOptinal", - Doc("Request configuration that was used."), - ], - ) -> Annotated[ - "Response", - Doc("Modified or original response object."), - ]: - if route.method not in self.cache_methods: - return response + async def response(self, response: "Response") -> "Response": + key, cached = self._state.get() - cached = config.get("_cache_hit") if cached is not None: return cached - key = self._generate_key(route) - - async with self._lock: - if len(self._cache) >= self.max_size: - self._cache.popitem(last=False) - - self._cache[key] = CacheEntry(response, self.ttl) + if key is not None: + async with self._lock: + if len(self._cache) >= self.max_size: + self._cache.popitem(last=False) + self._cache[key] = CacheEntry(response, self.ttl) return response - async def on_error( - self, - error: Annotated[ - Exception, - Doc("The exception that occurred."), - ], - route: Annotated[ - "Route", - Doc("The route that failed."), - ], - config: Annotated[ - "RequestsOptinal", - Doc("Request configuration that was used."), - ], - ) -> None: - key = self._generate_key(route) - async with self._lock: - self._cache.pop(key, None) + async def on_error(self, error: Exception, route: "Route", config: "RequestsOptinal") -> None: + key, _ = self._state.get() + if key is not None: + async with self._lock: + self._cache.pop(key, None) - def clear(self) -> Annotated[ - None, - Doc("Clears all cached responses."), - ]: + def clear(self) -> None: """Clear all cached responses.""" self._cache.clear() - def get_stats(self) -> Annotated[ - dict, - Doc("Returns cache statistics."), - ]: + def get_stats(self) -> dict: + """Return cache statistics.""" return { "size": len(self._cache), "max_size": self.max_size, diff --git a/fasthttp/types.py b/fasthttp/types.py index a5246c4..9a21173 100644 --- a/fasthttp/types.py +++ b/fasthttp/types.py @@ -1,4 +1,6 @@ -from typing import Annotated, TypeAlias, TypedDict +from typing import Annotated, Literal, TypeAlias, TypedDict + +HTTPMethod: TypeAlias = Literal["GET", "POST", "PUT", "PATCH", "DELETE"] from annotated_doc import Doc diff --git a/tests/conftest.py b/tests/conftest.py index 2f58da4..76b84ad 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -60,6 +60,6 @@ def http_client(mock_logger, request_configs) -> HTTPClient: @pytest.fixture -def middleware_manager(mock_logger) -> MM: +def middleware_manager() -> MM: """Create a MiddlewareManager instance for testing.""" - return MM(logger=mock_logger) + return MM() diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index a053891..ad60f1c 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -9,6 +9,8 @@ FastHTTPValidationError, log_success, ) +from fasthttp.exceptions.base import FastHTTPError +from fasthttp.exceptions.types import colorize, handle_error class TestExceptions: @@ -126,3 +128,163 @@ def test_log_success_includes_timing(self) -> None: # Just verify it doesn't crash log_success("http://example.com", "GET", 200, 100.0) assert True + + +class TestFastHTTPErrorBase: + def test_is_exception_subclass(self): + assert issubclass(FastHTTPError, Exception) + + def test_str_contains_message(self): + err = FastHTTPError("something broke") + assert "something broke" in str(err) + + def test_str_contains_url(self): + url = "https://x.com" + err = FastHTTPError("oops", url=url) + assert err.url == url + + def test_str_contains_method(self): + err = FastHTTPError("oops", method="DELETE") + assert "DELETE" in str(err) + + def test_str_contains_status(self): + err = FastHTTPError("oops", status_code=503) + assert "503" in str(err) + + def test_details_default_empty_dict(self): + err = FastHTTPError("oops") + assert err.details == {} + + def test_details_stored(self): + err = FastHTTPError("oops", details={"key": "val"}) + assert err.details["key"] == "val" + + def test_details_none_becomes_empty(self): + err = FastHTTPError("oops", details=None) + assert err.details == {} + + def test_str_contains_details(self): + err = FastHTTPError("oops", details={"foo": "bar"}) + assert "foo" in str(err) + + def test_log_does_not_raise(self): + err = FastHTTPError("oops", url="u", method="GET", status_code=500) + err.log() + + def test_log_with_custom_level(self): + err = FastHTTPError("oops") + err.log(level=logging.WARNING) + + def test_can_be_raised_and_caught(self): + with pytest.raises(FastHTTPError): + raise FastHTTPError("test raise") + + +class TestFastHTTPBadStatusErrorExtended: + def test_default_message_no_status(self): + err = FastHTTPBadStatusError() + assert err.message == "Bad status" + + def test_short_body_not_truncated(self): + err = FastHTTPBadStatusError(response_body="short") + assert err.details["body_preview"] == "short" + + def test_exactly_100_chars_not_truncated(self): + body = "x" * 100 + err = FastHTTPBadStatusError(response_body=body) + assert err.details["body_preview"] == body + assert not err.details["body_preview"].endswith("...") + + def test_101_chars_truncated(self): + body = "x" * 101 + err = FastHTTPBadStatusError(response_body=body) + assert err.details["body_preview"].endswith("...") + + def test_is_subclass_of_base(self): + assert issubclass(FastHTTPBadStatusError, FastHTTPError) + + def test_can_be_raised(self): + with pytest.raises(FastHTTPBadStatusError): + raise FastHTTPBadStatusError(status_code=404) + + +class TestFastHTTPTimeoutErrorExtended: + def test_default_message(self): + err = FastHTTPTimeoutError() + assert err.message == "Request timed out" + + def test_no_timeout_no_details(self): + err = FastHTTPTimeoutError() + assert "timeout" not in err.details + + def test_timeout_stored_in_details(self): + err = FastHTTPTimeoutError(timeout=30) + assert err.details["timeout"] == 30 + + def test_is_subclass_of_base(self): + assert issubclass(FastHTTPTimeoutError, FastHTTPError) + + +class TestFastHTTPConnectionErrorExtended: + def test_default_message(self): + err = FastHTTPConnectionError() + assert err.message == "Connection failed" + + def test_is_subclass_of_base(self): + assert issubclass(FastHTTPConnectionError, FastHTTPError) + + def test_can_be_raised(self): + with pytest.raises(FastHTTPConnectionError): + raise FastHTTPConnectionError(url="https://x.com") + + +class TestFastHTTPValidationErrorExtended: + def test_default_message(self): + err = FastHTTPValidationError() + assert err.message == "Validation failed" + + def test_is_subclass_of_base(self): + assert issubclass(FastHTTPValidationError, FastHTTPError) + + +class TestFastHTTPRequestErrorExtended: + def test_is_subclass_of_base(self): + assert issubclass(FastHTTPRequestError, FastHTTPError) + + def test_can_be_raised(self): + with pytest.raises(FastHTTPRequestError): + raise FastHTTPRequestError("bad request") + + +class TestColorize: + def test_colorize_returns_string(self): + result = colorize("hello", "red") + assert isinstance(result, str) + + def test_colorize_contains_original_text(self): + result = colorize("world", "blue") + assert "world" in result + + def test_colorize_contains_reset(self): + result = colorize("text", "green") + assert "\033[0m" in result + + def test_colorize_unknown_color_no_crash(self): + result = colorize("text", "purple_unicorn") + assert "text" in result + + +class TestHandleError: + def test_handle_error_raises_by_default(self): + err = FastHTTPError("boom") + with pytest.raises(FastHTTPError): + handle_error(err) + + def test_handle_error_no_raise(self): + err = FastHTTPError("boom") + handle_error(err, raise_it=False) + + def test_handle_error_raises_correct_type(self): + err = FastHTTPBadStatusError(status_code=500) + with pytest.raises(FastHTTPBadStatusError): + handle_error(err) diff --git a/tests/test_helpers.py b/tests/test_helpers.py new file mode 100644 index 0000000..1e87826 --- /dev/null +++ b/tests/test_helpers.py @@ -0,0 +1,223 @@ +"""Tests for helpers/route_inspect.py.""" +import inspect +import pytest +from pydantic import BaseModel + +from fasthttp.helpers.route_inspect import ( + check_annotated_parameters, + check_annotated_return, + validate_handler, + create_route_params, + COMMON_PARAMS, +) + + +# --------------------------------------------------------------------------- +# check_annotated_parameters +# --------------------------------------------------------------------------- + +class TestCheckAnnotatedParameters: + def test_fully_annotated_passes(self): + def fn(x: int, y: str) -> None: + pass + check_annotated_parameters(func=fn) + + def test_no_params_passes(self): + def fn() -> None: + pass + check_annotated_parameters(func=fn) + + def test_missing_annotation_raises(self): + def fn(x) -> None: + pass + with pytest.raises(TypeError, match="x"): + check_annotated_parameters(func=fn) + + def test_partial_annotation_raises(self): + def fn(x: int, y) -> None: + pass + with pytest.raises(TypeError, match="y"): + check_annotated_parameters(func=fn) + + def test_error_message_contains_function_name(self): + def my_func(unannotated) -> None: + pass + with pytest.raises(TypeError, match="my_func"): + check_annotated_parameters(func=my_func) + + def test_async_func_passes(self): + async def fn(x: int) -> None: + pass + check_annotated_parameters(func=fn) + + +# --------------------------------------------------------------------------- +# check_annotated_return +# --------------------------------------------------------------------------- + +class TestCheckAnnotatedReturn: + def test_annotated_return_passes(self): + def fn() -> int: + return 1 + check_annotated_return(func=fn) + + def test_none_return_passes(self): + def fn() -> None: + pass + check_annotated_return(func=fn) + + def test_missing_return_raises(self): + def fn(): + pass + with pytest.raises(TypeError, match="return type"): + check_annotated_return(func=fn) + + def test_error_message_contains_function_name(self): + def my_handler(): + pass + with pytest.raises(TypeError, match="my_handler"): + check_annotated_return(func=my_handler) + + def test_complex_return_type_passes(self): + def fn() -> dict[str, list[int]]: + return {} + check_annotated_return(func=fn) + + +# --------------------------------------------------------------------------- +# validate_handler +# --------------------------------------------------------------------------- + +class TestValidateHandler: + def test_valid_handler_passes(self): + def fn(x: int, y: str) -> bool: + return True + validate_handler(fn) + + def test_missing_param_annotation_raises(self): + def fn(x) -> bool: + return True + with pytest.raises(TypeError): + validate_handler(fn) + + def test_missing_return_annotation_raises(self): + def fn(x: int): + pass + with pytest.raises(TypeError): + validate_handler(fn) + + def test_both_missing_raises(self): + def fn(x): + pass + with pytest.raises(TypeError): + validate_handler(fn) + + def test_async_valid_handler_passes(self): + async def fn(x: int) -> str: + return str(x) + validate_handler(fn) + + +# --------------------------------------------------------------------------- +# create_route_params +# --------------------------------------------------------------------------- + +class TestCreateRouteParams: + def test_minimal_returns_dict(self): + result = create_route_params(method="GET", url="https://example.com") + assert isinstance(result, dict) + assert result["method"] == "GET" + assert result["url"] == "https://example.com" + + def test_defaults(self): + result = create_route_params(method="POST", url="https://example.com") + assert result["params"] is None + assert result["json"] is None + assert result["data"] is None + assert result["response_model"] is None + assert result["request_model"] is None + assert result["tags"] == [] + assert result["dependencies"] == [] + assert result["skip_request"] is False + assert result["responses"] == {} + + def test_none_tags_becomes_empty_list(self): + result = create_route_params(method="GET", url="u", tags=None) + assert result["tags"] == [] + + def test_none_dependencies_becomes_empty_list(self): + result = create_route_params(method="GET", url="u", dependencies=None) + assert result["dependencies"] == [] + + def test_none_responses_becomes_empty_dict(self): + result = create_route_params(method="GET", url="u", responses=None) + assert result["responses"] == {} + + def test_params_forwarded(self): + result = create_route_params(method="GET", url="u", params={"q": "1"}) + assert result["params"] == {"q": "1"} + + def test_json_forwarded(self): + result = create_route_params(method="POST", url="u", json={"a": 1}) + assert result["json"] == {"a": 1} + + def test_data_forwarded(self): + result = create_route_params(method="POST", url="u", data=b"bytes") + assert result["data"] == b"bytes" + + def test_skip_request_forwarded(self): + result = create_route_params(method="GET", url="u", skip_request=True) + assert result["skip_request"] is True + + def test_response_model_forwarded(self): + class M(BaseModel): + x: int + result = create_route_params(method="GET", url="u", response_model=M) + assert result["response_model"] is M + + def test_request_model_forwarded(self): + class M(BaseModel): + x: int + result = create_route_params(method="POST", url="u", request_model=M) + assert result["request_model"] is M + + def test_tags_forwarded(self): + result = create_route_params(method="GET", url="u", tags=["a", "b"]) + assert result["tags"] == ["a", "b"] + + def test_all_http_methods(self): + for m in ["GET", "POST", "PUT", "PATCH", "DELETE"]: + result = create_route_params(method=m, url="u") + assert result["method"] == m + + +# --------------------------------------------------------------------------- +# COMMON_PARAMS +# --------------------------------------------------------------------------- + +class TestCommonParams: + def test_common_params_exists(self): + assert COMMON_PARAMS is not None + + def test_response_model_key(self): + assert "response_model" in COMMON_PARAMS + + def test_request_model_key(self): + assert "request_model" in COMMON_PARAMS + + def test_tags_key(self): + assert "tags" in COMMON_PARAMS + + def test_dependencies_key(self): + assert "dependencies" in COMMON_PARAMS + + def test_responses_key(self): + assert "responses" in COMMON_PARAMS + + def test_each_entry_has_type(self): + for key, val in COMMON_PARAMS.items(): + assert "type" in val, f"{key} missing 'type'" + + def test_each_entry_has_default(self): + for key, val in COMMON_PARAMS.items(): + assert "default" in val, f"{key} missing 'default'" diff --git a/tests/test_logging.py b/tests/test_logging.py new file mode 100644 index 0000000..db0524b --- /dev/null +++ b/tests/test_logging.py @@ -0,0 +1,150 @@ +"""Tests for ColorFormatter and setup_logger.""" +import logging +import pytest + +from fasthttp.logging import ColorFormatter, setup_logger, LOGGER_NAME + + +class TestColorFormatter: + def test_format_debug(self): + formatter = ColorFormatter("%(levelname)s %(message)s") + record = logging.LogRecord( + name="test", level=logging.DEBUG, + pathname="", lineno=0, msg="debug msg", + args=(), exc_info=None, + ) + result = formatter.format(record) + assert "debug msg" in result + + def test_format_info(self): + formatter = ColorFormatter("%(levelname)s %(message)s") + record = logging.LogRecord( + name="test", level=logging.INFO, + pathname="", lineno=0, msg="info msg", + args=(), exc_info=None, + ) + result = formatter.format(record) + assert "info msg" in result + + def test_format_warning(self): + formatter = ColorFormatter("%(levelname)s %(message)s") + record = logging.LogRecord( + name="test", level=logging.WARNING, + pathname="", lineno=0, msg="warn msg", + args=(), exc_info=None, + ) + result = formatter.format(record) + assert "warn msg" in result + + def test_format_error(self): + formatter = ColorFormatter("%(levelname)s %(message)s") + record = logging.LogRecord( + name="test", level=logging.ERROR, + pathname="", lineno=0, msg="error msg", + args=(), exc_info=None, + ) + result = formatter.format(record) + assert "error msg" in result + + def test_format_critical(self): + formatter = ColorFormatter("%(levelname)s %(message)s") + record = logging.LogRecord( + name="test", level=logging.CRITICAL, + pathname="", lineno=0, msg="critical msg", + args=(), exc_info=None, + ) + result = formatter.format(record) + assert "critical msg" in result + + def test_format_result_prefix(self): + formatter = ColorFormatter("%(message)s") + record = logging.LogRecord( + name="test", level=logging.INFO, + pathname="", lineno=0, msg="[RESULT] done", + args=(), exc_info=None, + ) + result = formatter.format(record) + assert "done" in result + + def test_format_time_returns_string(self): + formatter = ColorFormatter() + record = logging.LogRecord( + name="test", level=logging.INFO, + pathname="", lineno=0, msg="x", + args=(), exc_info=None, + ) + ts = formatter.formatTime(record) + assert isinstance(ts, str) + assert ":" in ts + + def test_level_colors_defined_for_all_standard_levels(self): + for level in [logging.DEBUG, logging.INFO, logging.WARNING, logging.ERROR, logging.CRITICAL]: + assert level in ColorFormatter.LEVEL_COLORS + + def test_level_icons_defined_for_all_standard_levels(self): + for level in [logging.DEBUG, logging.INFO, logging.WARNING, logging.ERROR, logging.CRITICAL]: + assert level in ColorFormatter.LEVEL_ICONS + + def test_levelname_contains_ansi_reset(self): + formatter = ColorFormatter("%(levelname)s") + record = logging.LogRecord( + name="test", level=logging.DEBUG, + pathname="", lineno=0, msg="x", + args=(), exc_info=None, + ) + formatter.format(record) + assert "\033[0m" in record.levelname + + def test_unknown_level_uses_reset_color(self): + formatter = ColorFormatter("%(message)s") + record = logging.LogRecord( + name="test", level=99, + pathname="", lineno=0, msg="custom level", + args=(), exc_info=None, + ) + result = formatter.format(record) + assert "custom level" in result + + +class TestSetupLogger: + def setup_method(self): + logger = logging.getLogger(LOGGER_NAME) + logger.handlers.clear() + + def test_returns_logger(self): + logger = setup_logger() + assert isinstance(logger, logging.Logger) + + def test_logger_name(self): + logger = setup_logger() + assert logger.name == LOGGER_NAME + + def test_logger_has_handler(self): + logger = setup_logger() + assert len(logger.handlers) >= 1 + + def test_logger_does_not_propagate(self): + logger = setup_logger() + assert logger.propagate is False + + def test_second_call_returns_same_logger(self): + logger1 = setup_logger() + handler_count = len(logger1.handlers) + logger2 = setup_logger() + assert logger1 is logger2 + assert len(logger2.handlers) == handler_count + + def test_debug_mode_sets_handler_level(self): + logger = setup_logger(debug=True) + handler = logger.handlers[0] + assert handler.level == logging.DEBUG + + def test_non_debug_sets_info_level(self): + logger = setup_logger(debug=False) + handler = logger.handlers[0] + assert handler.level == logging.INFO + + def test_handler_has_color_formatter(self): + logger = setup_logger() + handler = logger.handlers[0] + assert isinstance(handler.formatter, ColorFormatter) diff --git a/tests/test_meta.py b/tests/test_meta.py new file mode 100644 index 0000000..5cd5341 --- /dev/null +++ b/tests/test_meta.py @@ -0,0 +1,24 @@ +"""Tests for __meta__.py version string.""" +import fasthttp.__meta__ as meta + + +class TestMeta: + def test_version_exists(self): + assert hasattr(meta, "__version__") + + def test_version_is_string(self): + assert isinstance(meta.__version__, str) + + def test_version_not_empty(self): + assert meta.__version__ != "" + + def test_version_semver_format(self): + parts = meta.__version__.split(".") + assert len(parts) >= 2 + for part in parts: + assert part.isdigit(), f"Non-numeric part: {part!r}" + + def test_version_importable_from_fasthttp(self): + import fasthttp + assert hasattr(fasthttp, "__version__") + assert fasthttp.__version__ == meta.__version__ diff --git a/tests/test_middleware.py b/tests/test_middleware.py new file mode 100644 index 0000000..6ffe660 --- /dev/null +++ b/tests/test_middleware.py @@ -0,0 +1,859 @@ +"""Comprehensive tests for fasthttp middleware system.""" +import asyncio +import time +from typing import ClassVar +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from fasthttp.middleware import ( + BaseMiddleware, + CacheEntry, + CacheMiddleware, + MiddlewareChain, + MiddlewareManager, +) +from fasthttp.response import Response +from fasthttp.routing import Route +from fasthttp.types import HTTPMethod + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def make_route( + method: str = "GET", + url: str = "https://example.com/api", + params: dict | None = None, +) -> Route: + async def handler(resp: Response) -> Response: + return resp + + return Route(method=method, url=url, handler=handler, params=params) + + +def make_response(status: int = 200, text: str = "ok") -> Response: + return Response(status=status, text=text, headers={}, method="GET") + + +class SimpleMiddleware(BaseMiddleware): + __return_type__: ClassVar = None + __priority__: ClassVar[int] = 0 + __methods__: ClassVar[list[HTTPMethod] | None] = None + __enabled__: ClassVar[bool] = True + + def __init__(self, name: str = "simple") -> None: + self.name = name + self.requests: list[str] = [] + self.responses: list[int] = [] + self.errors: list[str] = [] + + async def request(self, method, url, kwargs): + self.requests.append(f"{method}:{url}") + kwargs["headers"] = kwargs.get("headers") or {} + kwargs["headers"][f"X-{self.name}"] = "1" + return kwargs + + async def response(self, response): + self.responses.append(response.status) + return response + + async def on_error(self, error, route, config): + self.errors.append(type(error).__name__) + + +class PriorityMiddleware(BaseMiddleware): + __return_type__: ClassVar = None + __methods__: ClassVar[list[HTTPMethod] | None] = None + __enabled__: ClassVar[bool] = True + + def __init__(self, priority: int, order_log: list) -> None: + self.__priority__ = priority + self._order = order_log + + async def request(self, method, url, kwargs): + self._order.append(f"req:{self.__priority__}") + return kwargs + + async def response(self, response): + self._order.append(f"res:{self.__priority__}") + return response + + +# --------------------------------------------------------------------------- +# BaseMiddleware +# --------------------------------------------------------------------------- + +class TestBaseMiddleware: + def test_repr_with_return_type(self): + class Mw(BaseMiddleware): + __return_type__ = bool + __priority__ = 0 + __methods__ = None + __enabled__ = True + + assert repr(Mw()) == ">" + + def test_repr_without_return_type(self): + mw = SimpleMiddleware() + assert "SimpleMiddleware" in repr(mw) + assert "return_type=None" in repr(mw) + + def test_or_returns_middleware_chain(self): + a = SimpleMiddleware("a") + b = SimpleMiddleware("b") + chain = a | b + assert isinstance(chain, MiddlewareChain) + assert len(chain) == 2 + + def test_or_chaining_three(self): + a = SimpleMiddleware("a") + b = SimpleMiddleware("b") + c = SimpleMiddleware("c") + chain = a | b | c + assert len(chain) == 3 + + @pytest.mark.asyncio + async def test_default_request_returns_kwargs(self): + mw = BaseMiddleware() + kwargs = {"headers": {"X-Test": "1"}} + result = await mw.request("GET", "https://example.com", kwargs) + assert result is kwargs + + @pytest.mark.asyncio + async def test_default_response_returns_response(self): + mw = BaseMiddleware() + resp = make_response() + result = await mw.response(resp) + assert result is resp + + @pytest.mark.asyncio + async def test_default_on_error_returns_none(self): + mw = BaseMiddleware() + route = make_route() + result = await mw.on_error(ValueError("boom"), route, {}) + assert result is None + + def test_init_subclass_called(self): + called = [] + + class Base(BaseMiddleware): + def __init_subclass__(cls, **kwargs): + super().__init_subclass__(**kwargs) + called.append(cls.__name__) + + class Child(Base): + pass + + assert "Child" in called + + def test_class_attrs_annotations(self): + annotations = BaseMiddleware.__annotations__ + assert "__return_type__" in annotations + assert "__priority__" in annotations + assert "__methods__" in annotations + assert "__enabled__" in annotations + + def test_class_var_in_annotations(self): + ann = str(BaseMiddleware.__annotations__["__methods__"]) + assert "ClassVar" in ann + assert "HTTPMethod" in ann + + +# --------------------------------------------------------------------------- +# MiddlewareChain +# --------------------------------------------------------------------------- + +class TestMiddlewareChain: + def test_len(self): + chain = MiddlewareChain([SimpleMiddleware("a"), SimpleMiddleware("b")]) + assert len(chain) == 2 + + def test_iter(self): + a, b = SimpleMiddleware("a"), SimpleMiddleware("b") + chain = MiddlewareChain([a, b]) + assert list(chain) == [a, b] + + def test_or_appends(self): + a = SimpleMiddleware("a") + b = SimpleMiddleware("b") + c = SimpleMiddleware("c") + chain = MiddlewareChain([a, b]) | c + assert len(chain) == 3 + assert list(chain)[2] is c + + def test_repr(self): + chain = MiddlewareChain([SimpleMiddleware("a"), SimpleMiddleware("b")]) + r = repr(chain) + assert "MiddlewareChain" in r + assert "SimpleMiddleware" in r + + def test_pipe_operator_creates_chain(self): + a = SimpleMiddleware("a") + b = SimpleMiddleware("b") + chain = a | b + items = list(chain) + assert items[0] is a + assert items[1] is b + + def test_pipe_chain_then_pipe_more(self): + a = SimpleMiddleware("a") + b = SimpleMiddleware("b") + c = SimpleMiddleware("c") + chain = (a | b) | c + assert len(chain) == 3 + + +# --------------------------------------------------------------------------- +# MiddlewareManager — init +# --------------------------------------------------------------------------- + +class TestMiddlewareManagerInit: + def test_init_none(self): + mm = MiddlewareManager(None) + assert mm.middlewares == [] + + def test_init_empty_list(self): + mm = MiddlewareManager([]) + assert mm.middlewares == [] + + def test_init_list(self): + a, b = SimpleMiddleware("a"), SimpleMiddleware("b") + mm = MiddlewareManager([a, b]) + assert len(mm.middlewares) == 2 + + def test_init_chain(self): + a, b = SimpleMiddleware("a"), SimpleMiddleware("b") + chain = a | b + mm = MiddlewareManager(chain) + assert len(mm.middlewares) == 2 + assert mm.middlewares[0] is a + + def test_default_no_args(self): + mm = MiddlewareManager() + assert mm.middlewares == [] + + +# --------------------------------------------------------------------------- +# MiddlewareManager — sorting and filtering +# --------------------------------------------------------------------------- + +class TestMiddlewareManagerSortingFiltering: + def test_sorted_by_priority(self): + order = [] + low = PriorityMiddleware(0, order) + high = PriorityMiddleware(10, order) + mm = MiddlewareManager([high, low]) + sorted_mws = mm._sorted() + assert sorted_mws[0].__priority__ == 0 + assert sorted_mws[1].__priority__ == 10 + + def test_sorted_stable_equal_priority(self): + a = SimpleMiddleware("a") + b = SimpleMiddleware("b") + mm = MiddlewareManager([a, b]) + result = mm._sorted() + assert result[0] is a + assert result[1] is b + + def test_active_skips_disabled(self): + a = SimpleMiddleware("a") + b = SimpleMiddleware("b") + b.__enabled__ = False + mm = MiddlewareManager([a, b]) + active = mm._active(mm._sorted(), "GET") + assert len(active) == 1 + assert active[0] is a + + def test_active_method_filter_matches(self): + class PostOnly(BaseMiddleware): + __return_type__ = None + __priority__ = 0 + __methods__ = ["POST"] + __enabled__ = True + + mm = MiddlewareManager([PostOnly()]) + assert len(mm._active(mm._sorted(), "POST")) == 1 + assert len(mm._active(mm._sorted(), "GET")) == 0 + + def test_active_method_filter_case_insensitive(self): + class GetOnly(BaseMiddleware): + __return_type__ = None + __priority__ = 0 + __methods__ = ["get"] + __enabled__ = True + + mm = MiddlewareManager([GetOnly()]) + assert len(mm._active(mm._sorted(), "GET")) == 1 + + def test_active_none_methods_matches_all(self): + mm = MiddlewareManager([SimpleMiddleware()]) + for method in ["GET", "POST", "PUT", "PATCH", "DELETE"]: + assert len(mm._active(mm._sorted(), method)) == 1 + + def test_active_disabled_and_method_filter(self): + class PostOnly(BaseMiddleware): + __return_type__ = None + __priority__ = 0 + __methods__ = ["POST"] + __enabled__ = False + + mm = MiddlewareManager([PostOnly()]) + assert mm._active(mm._sorted(), "POST") == [] + + def test_runtime_toggle_enabled(self): + mw = SimpleMiddleware() + mm = MiddlewareManager([mw]) + assert len(mm._active(mm._sorted(), "GET")) == 1 + mw.__enabled__ = False + assert len(mm._active(mm._sorted(), "GET")) == 0 + mw.__enabled__ = True + assert len(mm._active(mm._sorted(), "GET")) == 1 + + +# --------------------------------------------------------------------------- +# MiddlewareManager — process_before_request +# --------------------------------------------------------------------------- + +class TestProcessBeforeRequest: + @pytest.mark.asyncio + async def test_header_added_by_middleware(self): + mw = SimpleMiddleware("auth") + mm = MiddlewareManager([mw]) + route = make_route() + result = await mm.process_before_request(route, {}) + assert result["headers"]["X-auth"] == "1" + + @pytest.mark.asyncio + async def test_request_order_matches_priority(self): + order = [] + mm = MiddlewareManager([ + PriorityMiddleware(10, order), + PriorityMiddleware(0, order), + ]) + route = make_route() + await mm.process_before_request(route, {}) + assert order.index("req:0") < order.index("req:10") + + @pytest.mark.asyncio + async def test_params_added_from_route(self): + route = make_route(params={"q": "test"}) + mm = MiddlewareManager([]) + result = await mm.process_before_request(route, {}) + assert result["params"] == {"q": "test"} + + @pytest.mark.asyncio + async def test_middleware_can_modify_params(self): + class ParamsMw(BaseMiddleware): + __return_type__ = None + __priority__ = 0 + __methods__ = None + __enabled__ = True + + async def request(self, method, url, kwargs): + kwargs["params"] = {"injected": "true"} + return kwargs + + mm = MiddlewareManager([ParamsMw()]) + route = make_route(params={"original": "1"}) + result = await mm.process_before_request(route, {}) + assert result["params"] == {"injected": "true"} + + @pytest.mark.asyncio + async def test_disabled_middleware_skipped(self): + mw = SimpleMiddleware("skipped") + mw.__enabled__ = False + mm = MiddlewareManager([mw]) + route = make_route() + result = await mm.process_before_request(route, {}) + assert "X-skipped" not in (result.get("headers") or {}) + + @pytest.mark.asyncio + async def test_empty_middleware_returns_config(self): + mm = MiddlewareManager() + route = make_route() + config = {"headers": {"X-Existing": "yes"}, "timeout": 10.0} + result = await mm.process_before_request(route, config) + assert result["headers"]["X-Existing"] == "yes" + + @pytest.mark.asyncio + async def test_multiple_middlewares_chain_kwargs(self): + class Add1(BaseMiddleware): + __return_type__ = None + __priority__ = 0 + __methods__ = None + __enabled__ = True + + async def request(self, method, url, kwargs): + kwargs["headers"] = kwargs.get("headers") or {} + kwargs["headers"]["X-Step"] = "1" + return kwargs + + class Add2(BaseMiddleware): + __return_type__ = None + __priority__ = 1 + __methods__ = None + __enabled__ = True + + async def request(self, method, url, kwargs): + kwargs["headers"]["X-Step2"] = "2" + return kwargs + + mm = MiddlewareManager([Add1(), Add2()]) + route = make_route() + result = await mm.process_before_request(route, {}) + assert result["headers"]["X-Step"] == "1" + assert result["headers"]["X-Step2"] == "2" + + @pytest.mark.asyncio + async def test_method_filtered_middleware_not_called(self): + class PostOnly(BaseMiddleware): + __return_type__ = None + __priority__ = 0 + __methods__ = ["POST"] + __enabled__ = True + called = False + + async def request(self, method, url, kwargs): + PostOnly.called = True + return kwargs + + mm = MiddlewareManager([PostOnly()]) + route = make_route(method="GET") + await mm.process_before_request(route, {}) + assert not PostOnly.called + + +# --------------------------------------------------------------------------- +# MiddlewareManager — process_after_response +# --------------------------------------------------------------------------- + +class TestProcessAfterResponse: + @pytest.mark.asyncio + async def test_response_called_in_reverse_priority(self): + order = [] + mm = MiddlewareManager([ + PriorityMiddleware(0, order), + PriorityMiddleware(10, order), + ]) + route = make_route() + resp = make_response() + await mm.process_before_request(route, {}) + order.clear() + await mm.process_after_response(resp, route, {}) + assert order.index("res:10") < order.index("res:0") + + @pytest.mark.asyncio + async def test_response_can_modify_response(self): + class StatusMw(BaseMiddleware): + __return_type__ = None + __priority__ = 0 + __methods__ = None + __enabled__ = True + + async def response(self, response): + response.text = "modified" + return response + + mm = MiddlewareManager([StatusMw()]) + route = make_route() + resp = make_response(text="original") + result = await mm.process_after_response(resp, route, {}) + assert result.text == "modified" + + @pytest.mark.asyncio + async def test_response_disabled_middleware_skipped(self): + mw = SimpleMiddleware() + mw.__enabled__ = False + mm = MiddlewareManager([mw]) + route = make_route() + resp = make_response() + await mm.process_after_response(resp, route, {}) + assert mw.responses == [] + + @pytest.mark.asyncio + async def test_response_method_filter_applied(self): + class GetOnly(BaseMiddleware): + __return_type__ = None + __priority__ = 0 + __methods__ = ["GET"] + __enabled__ = True + called = False + + async def response(self, response): + GetOnly.called = True + return response + + mm = MiddlewareManager([GetOnly()]) + resp = make_response() + await mm.process_after_response(resp, make_route(method="POST"), {}) + assert not GetOnly.called + + await mm.process_after_response(resp, make_route(method="GET"), {}) + assert GetOnly.called + + @pytest.mark.asyncio + async def test_empty_middleware_returns_response_unchanged(self): + mm = MiddlewareManager() + resp = make_response(status=201, text="created") + result = await mm.process_after_response(resp, make_route(), {}) + assert result.status == 201 + assert result.text == "created" + + +# --------------------------------------------------------------------------- +# MiddlewareManager — process_on_error +# --------------------------------------------------------------------------- + +class TestProcessOnError: + @pytest.mark.asyncio + async def test_on_error_called(self): + mw = SimpleMiddleware() + mm = MiddlewareManager([mw]) + route = make_route() + error = ConnectionError("refused") + await mm.process_on_error(error, route, {}) + assert "ConnectionError" in mw.errors + + @pytest.mark.asyncio + async def test_on_error_skips_disabled(self): + mw = SimpleMiddleware() + mw.__enabled__ = False + mm = MiddlewareManager([mw]) + await mm.process_on_error(ValueError(), make_route(), {}) + assert mw.errors == [] + + @pytest.mark.asyncio + async def test_on_error_called_for_all_active(self): + a, b = SimpleMiddleware("a"), SimpleMiddleware("b") + mm = MiddlewareManager([a, b]) + await mm.process_on_error(RuntimeError("oops"), make_route(), {}) + assert len(a.errors) == 1 + assert len(b.errors) == 1 + + @pytest.mark.asyncio + async def test_on_error_method_filter(self): + class PostOnly(BaseMiddleware): + __return_type__ = None + __priority__ = 0 + __methods__ = ["POST"] + __enabled__ = True + called = False + + async def on_error(self, error, route, config): + PostOnly.called = True + + mm = MiddlewareManager([PostOnly()]) + await mm.process_on_error(ValueError(), make_route(method="GET"), {}) + assert not PostOnly.called + + +# --------------------------------------------------------------------------- +# CacheMiddleware +# --------------------------------------------------------------------------- + +class TestCacheMiddleware: + @pytest.mark.asyncio + async def test_cache_miss_stores_response(self): + cache = CacheMiddleware(ttl=60) + route = make_route(params={"q": "test"}) + kwargs = {"params": route.params} + + await cache.request("GET", route.url, kwargs) + resp = make_response(text="fresh") + result = await cache.response(resp) + + assert result.text == "fresh" + assert len(cache._cache) == 1 + + @pytest.mark.asyncio + async def test_cache_hit_returns_cached(self): + cache = CacheMiddleware(ttl=60) + kwargs = {"params": None} + + await cache.request("GET", "https://example.com", dict(kwargs)) + resp1 = make_response(text="original") + await cache.response(resp1) + + await cache.request("GET", "https://example.com", dict(kwargs)) + resp2 = make_response(text="new") + result = await cache.response(resp2) + + assert result.text == "original" + + @pytest.mark.asyncio + async def test_cache_different_urls_separate_entries(self): + cache = CacheMiddleware(ttl=60) + kwargs = {"params": None} + + await cache.request("GET", "https://a.com", dict(kwargs)) + await cache.response(make_response(text="a")) + + await cache.request("GET", "https://b.com", dict(kwargs)) + await cache.response(make_response(text="b")) + + assert len(cache._cache) == 2 + + @pytest.mark.asyncio + async def test_cache_different_params_separate_entries(self): + cache = CacheMiddleware(ttl=60) + url = "https://example.com/search" + + await cache.request("GET", url, {"params": {"q": "foo"}}) + await cache.response(make_response(text="foo")) + + await cache.request("GET", url, {"params": {"q": "bar"}}) + await cache.response(make_response(text="bar")) + + assert len(cache._cache) == 2 + + @pytest.mark.asyncio + async def test_cache_post_not_cached_by_default(self): + cache = CacheMiddleware(ttl=60) + kwargs = {"params": None} + + await cache.request("POST", "https://example.com", dict(kwargs)) + await cache.response(make_response(text="post")) + + assert len(cache._cache) == 0 + + @pytest.mark.asyncio + async def test_cache_custom_methods(self): + cache = CacheMiddleware(ttl=60, cache_methods=["POST"]) + kwargs = {"params": None} + + await cache.request("POST", "https://example.com", dict(kwargs)) + await cache.response(make_response(text="posted")) + + assert len(cache._cache) == 1 + + await cache.request("GET", "https://example.com", dict(kwargs)) + await cache.response(make_response(text="got")) + + assert len(cache._cache) == 1 + + @pytest.mark.asyncio + async def test_cache_ttl_expiry(self): + cache = CacheMiddleware(ttl=1) + kwargs = {"params": None} + + await cache.request("GET", "https://example.com", dict(kwargs)) + await cache.response(make_response(text="fresh")) + + await asyncio.sleep(1.1) + + await cache.request("GET", "https://example.com", dict(kwargs)) + key, cached = cache._state.get() + assert cached is None + + resp2 = make_response(text="refreshed") + result = await cache.response(resp2) + assert result.text == "refreshed" + + @pytest.mark.asyncio + async def test_cache_max_size_evicts_oldest(self): + cache = CacheMiddleware(ttl=60, max_size=2) + kwargs = {"params": None} + + for url in ["https://a.com", "https://b.com", "https://c.com"]: + await cache.request("GET", url, dict(kwargs)) + await cache.response(make_response(text=url)) + + assert len(cache._cache) == 2 + urls_in_cache = [e.response.text for e in cache._cache.values()] + assert "https://a.com" not in urls_in_cache + + @pytest.mark.asyncio + async def test_cache_clear(self): + cache = CacheMiddleware(ttl=60) + kwargs = {"params": None} + + await cache.request("GET", "https://example.com", dict(kwargs)) + await cache.response(make_response()) + + assert len(cache._cache) == 1 + cache.clear() + assert len(cache._cache) == 0 + + def test_cache_get_stats(self): + cache = CacheMiddleware(ttl=120, max_size=50, cache_methods=["GET", "POST"]) + stats = cache.get_stats() + assert stats["ttl"] == 120 + assert stats["max_size"] == 50 + assert stats["methods"] == ["GET", "POST"] + assert stats["size"] == 0 + + @pytest.mark.asyncio + async def test_cache_on_error_invalidates(self): + cache = CacheMiddleware(ttl=60) + kwargs = {"params": None} + + await cache.request("GET", "https://example.com", dict(kwargs)) + await cache.response(make_response(text="cached")) + assert len(cache._cache) == 1 + + await cache.request("GET", "https://example.com", dict(kwargs)) + await cache.on_error(ConnectionError(), make_route(), {}) + assert len(cache._cache) == 0 + + @pytest.mark.asyncio + async def test_cache_context_isolation(self): + """Two concurrent requests don't share ContextVar state.""" + cache = CacheMiddleware(ttl=60) + results = [] + + async def task(url: str, text: str) -> None: + kwargs = {"params": None} + await cache.request("GET", url, kwargs) + resp = make_response(text=text) + result = await cache.response(resp) + results.append((url, result.text)) + + await asyncio.gather( + task("https://a.com", "response-a"), + task("https://b.com", "response-b"), + ) + + assert len(results) == 2 + assert len(cache._cache) == 2 + + @pytest.mark.asyncio + async def test_cache_lru_move_to_end_on_hit(self): + cache = CacheMiddleware(ttl=60, max_size=2) + kwargs = {"params": None} + + await cache.request("GET", "https://a.com", dict(kwargs)) + await cache.response(make_response(text="a")) + + await cache.request("GET", "https://b.com", dict(kwargs)) + await cache.response(make_response(text="b")) + + # access a — moves to end, b becomes oldest + await cache.request("GET", "https://a.com", dict(kwargs)) + await cache.response(make_response(text="a-fresh")) + + # add c — should evict b (oldest) + await cache.request("GET", "https://c.com", dict(kwargs)) + await cache.response(make_response(text="c")) + + texts = [e.response.text for e in cache._cache.values()] + assert "b" not in texts + assert "a" in texts + + +# --------------------------------------------------------------------------- +# CacheEntry +# --------------------------------------------------------------------------- + +class TestCacheEntry: + def test_expires_at_set_correctly(self): + resp = make_response() + before = time.time() + entry = CacheEntry(resp, ttl=60) + after = time.time() + assert before + 60 <= entry.expires_at <= after + 60 + + def test_not_expired(self): + entry = CacheEntry(make_response(), ttl=60) + assert time.time() < entry.expires_at + + def test_expired(self): + entry = CacheEntry(make_response(), ttl=0) + assert time.time() >= entry.expires_at + + +# --------------------------------------------------------------------------- +# HTTPMethod literal +# --------------------------------------------------------------------------- + +class TestHTTPMethod: + def test_http_method_values(self): + from fasthttp.types import HTTPMethod + from typing import get_args + args = get_args(HTTPMethod) + assert "GET" in args + assert "POST" in args + assert "PUT" in args + assert "PATCH" in args + assert "DELETE" in args + + def test_http_method_count(self): + from fasthttp.types import HTTPMethod + from typing import get_args + assert len(get_args(HTTPMethod)) == 5 + + +# --------------------------------------------------------------------------- +# Integration — FastHTTP + middleware +# --------------------------------------------------------------------------- + +class TestMiddlewareIntegration: + def test_fasthttp_accepts_list(self): + from fasthttp import FastHTTP + app = FastHTTP(middleware=[SimpleMiddleware("a"), SimpleMiddleware("b")]) + assert len(app.middleware_manager.middlewares) == 2 + + def test_fasthttp_accepts_chain(self): + from fasthttp import FastHTTP + chain = SimpleMiddleware("a") | SimpleMiddleware("b") + app = FastHTTP(middleware=chain) + assert len(app.middleware_manager.middlewares) == 2 + + def test_fasthttp_accepts_single(self): + from fasthttp import FastHTTP + app = FastHTTP(middleware=SimpleMiddleware()) + assert len(app.middleware_manager.middlewares) == 1 + + def test_fasthttp_no_middleware(self): + from fasthttp import FastHTTP + app = FastHTTP() + assert app.middleware_manager.middlewares == [] + + @pytest.mark.asyncio + async def test_full_request_response_cycle(self): + order = [] + + class TrackMw(BaseMiddleware): + __return_type__ = None + __priority__ = 0 + __methods__ = None + __enabled__ = True + + def __init__(self, name): + self.name = name + + async def request(self, method, url, kwargs): + order.append(f"req:{self.name}") + return kwargs + + async def response(self, response): + order.append(f"res:{self.name}") + return response + + a, b, c = TrackMw("a"), TrackMw("b"), TrackMw("c") + mm = MiddlewareManager([a, b, c]) + route = make_route() + resp = make_response() + + await mm.process_before_request(route, {}) + await mm.process_after_response(resp, route, {}) + + assert order == ["req:a", "req:b", "req:c", "res:c", "res:b", "res:a"] + + @pytest.mark.asyncio + async def test_cache_middleware_in_manager(self): + cache = CacheMiddleware(ttl=60) + mm = MiddlewareManager([cache]) + route = make_route(url="https://api.example.com/data") + + config1 = await mm.process_before_request(route, {}) + resp1 = make_response(text="data") + result1 = await mm.process_after_response(resp1, route, config1) + assert result1.text == "data" + assert cache.get_stats()["size"] == 1 + + config2 = await mm.process_before_request(route, {}) + resp2 = make_response(text="new-data") + result2 = await mm.process_after_response(resp2, route, config2) + assert result2.text == "data" diff --git a/tests/test_response.py b/tests/test_response.py index d069404..7122276 100644 --- a/tests/test_response.py +++ b/tests/test_response.py @@ -170,3 +170,114 @@ def test_response_handler_result_stored(self, sample_response) -> None: """Test that handler result is stored in response.""" sample_response._handler_result = "processed result" assert sample_response._handler_result == "processed result" + + +class TestResponseUrl: + def test_url_initially_none(self): + r = Response(status=200, text="", headers={}) + assert r.url is None + + def test_set_url(self): + r = Response(status=200, text="", headers={}) + r._set_url("https://example.com/path") + assert r.url == "https://example.com/path" + + def test_set_url_overwrites(self): + r = Response(status=200, text="", headers={}) + r._set_url("https://first.com") + r._set_url("https://second.com") + assert r.url == "https://second.com" + + def test_set_url_none(self): + r = Response(status=200, text="", headers={}) + r._set_url("https://example.com") + r._set_url(None) + assert r.url is None + + +class TestResponseAssets: + def test_assets_returns_dict_with_css_and_js(self): + r = Response(status=200, text="", headers={}) + result = r.assets() + assert "css" in result + assert "js" in result + + def test_assets_extracts_css_links(self): + html = '' + r = Response(status=200, text=html, headers={}) + r._set_url("https://example.com") + result = r.assets() + assert any("style.css" in link for link in result["css"]) + + def test_assets_extracts_js_links(self): + html = '' + r = Response(status=200, text=html, headers={}) + r._set_url("https://example.com") + result = r.assets() + assert any("app.js" in link for link in result["js"]) + + def test_assets_css_false_returns_empty_css(self): + html = '' + r = Response(status=200, text=html, headers={}) + result = r.assets(css=False) + assert result["css"] == [] + + def test_assets_js_false_returns_empty_js(self): + html = '' + r = Response(status=200, text=html, headers={}) + result = r.assets(js=False) + assert result["js"] == [] + + def test_assets_empty_html_returns_empty_lists(self): + r = Response(status=200, text="", headers={}) + result = r.assets() + assert result["css"] == [] + assert result["js"] == [] + + +class TestResponseReqText: + def test_req_text_json_priority_over_data(self): + r = Response( + status=200, text="", headers={}, + req_json={"x": 1}, req_data="ignored" + ) + result = r.req_text() + assert result == '{"x": 1}' + + def test_req_text_data_when_no_json(self): + r = Response(status=200, text="", headers={}, req_data="raw text") + result = r.req_text() + assert "raw text" in result + + def test_req_text_bytes_data(self): + r = Response(status=200, text="", headers={}, req_data=b"bytes") + result = r.req_text() + assert result is not None + + +class TestResponseDefaults: + def test_method_default_none(self): + r = Response(status=200, text="", headers={}) + assert r.method is None + + def test_req_headers_default_none(self): + r = Response(status=200, text="", headers={}) + assert r.req_headers is None + + def test_query_default_none(self): + r = Response(status=200, text="", headers={}) + assert r.query is None + + def test_handler_result_default_none(self): + r = Response(status=200, text="", headers={}) + assert r._handler_result is None + + def test_path_params_always_empty_dict(self): + r = Response(status=200, text="", headers={}, method="GET") + assert r.path_params == {} + assert isinstance(r.path_params, dict) + + def test_repr_format(self): + for status in [200, 201, 400, 404, 500]: + r = Response(status=status, text="", headers={}) + assert repr(r) == f"" diff --git a/tests/test_routing.py b/tests/test_routing.py new file mode 100644 index 0000000..e217308 --- /dev/null +++ b/tests/test_routing.py @@ -0,0 +1,451 @@ +"""Tests for Route, Router, and URL helpers.""" +import pytest +from pydantic import BaseModel + +from fasthttp.routing import Route, Router +from fasthttp.helpers.routing import ( + apply_base_url, + check_https_url, + join_prefix, + resolve_url, +) +from fasthttp.response import Response + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +async def dummy_handler(resp: Response) -> Response: + return resp + + +# --------------------------------------------------------------------------- +# check_https_url +# --------------------------------------------------------------------------- + +class TestCheckHttpsUrl: + def test_already_https(self): + assert check_https_url(url="https://example.com") == "https://example.com" + + def test_already_http(self): + assert check_https_url(url="http://example.com") == "http://example.com" + + def test_no_scheme_adds_https(self): + assert check_https_url(url="example.com") == "https://example.com" + + def test_strips_whitespace(self): + assert check_https_url(url=" example.com ") == "https://example.com" + + def test_path_only_no_scheme(self): + result = check_https_url(url="api.example.com/v1") + assert result.startswith("https://") + + def test_empty_string_adds_https(self): + result = check_https_url(url="") + assert result == "https://" + + +# --------------------------------------------------------------------------- +# join_prefix +# --------------------------------------------------------------------------- + +class TestJoinPrefix: + def test_empty_prefix_returns_url(self): + assert join_prefix("", "/users") == "/users" + + def test_empty_prefix_and_empty_url(self): + assert join_prefix("", "") == "" + + def test_prefix_and_path(self): + assert join_prefix("/v1", "/users") == "/v1/users" + + def test_prefix_without_leading_slash(self): + assert join_prefix("v1", "/users") == "/v1/users" + + def test_prefix_with_trailing_slash_stripped(self): + assert join_prefix("/v1/", "/users") == "/v1/users" + + def test_url_without_leading_slash(self): + assert join_prefix("/v1", "users") == "/v1/users" + + def test_url_is_root(self): + assert join_prefix("/v1", "/") == "/v1" + + def test_nested_prefix(self): + assert join_prefix("/api/v2", "/items") == "/api/v2/items" + + +# --------------------------------------------------------------------------- +# resolve_url +# --------------------------------------------------------------------------- + +class TestResolveUrl: + def test_absolute_url_returned_unchanged(self): + url = "https://example.com/api" + assert resolve_url(url=url, base_url=None, prefix="") == url + + def test_http_absolute_unchanged(self): + url = "http://example.com/api" + assert resolve_url(url=url, base_url=None, prefix="") == url + + def test_no_scheme_no_base_adds_https(self): + result = resolve_url(url="example.com/api", base_url=None, prefix="") + assert result == "https://example.com/api" + + def test_path_without_base_raises(self): + with pytest.raises(ValueError, match="base_url"): + resolve_url(url="/users", base_url=None, prefix="") + + def test_relative_with_base_url(self): + result = resolve_url(url="/users", base_url="https://api.example.com", prefix="") + assert result == "https://api.example.com/users" + + def test_relative_with_base_and_prefix(self): + result = resolve_url(url="/items", base_url="https://api.example.com", prefix="/v1") + assert result == "https://api.example.com/v1/items" + + def test_base_url_without_scheme(self): + result = resolve_url(url="/users", base_url="api.example.com", prefix="") + assert result.startswith("https://") + assert "users" in result + + +# --------------------------------------------------------------------------- +# apply_base_url +# --------------------------------------------------------------------------- + +class TestApplyBaseUrl: + def test_absolute_unchanged(self): + url = "https://example.com/path" + assert apply_base_url(url=url, base_url=None) == url + + def test_no_base_adds_https(self): + result = apply_base_url(url="example.com/path", base_url=None) + assert result == "https://example.com/path" + + def test_with_base_url(self): + result = apply_base_url(url="users", base_url="https://api.example.com") + assert result == "https://api.example.com/users" + + def test_with_base_url_trailing_slash(self): + result = apply_base_url(url="users", base_url="https://api.example.com/") + assert result == "https://api.example.com/users" + + def test_with_base_url_leading_slash(self): + result = apply_base_url(url="/users", base_url="https://api.example.com") + assert result == "https://api.example.com/users" + + +# --------------------------------------------------------------------------- +# Route +# --------------------------------------------------------------------------- + +class TestRoute: + def test_route_creation_minimal(self): + route = Route(method="GET", url="https://example.com", handler=dummy_handler) + assert route.method == "GET" + assert route.url == "https://example.com" + assert route.handler is dummy_handler + + def test_route_defaults(self): + route = Route(method="GET", url="https://example.com", handler=dummy_handler) + assert route.params is None + assert route.json is None + assert route.data is None + assert route.tags == [] + assert route.dependencies == [] + assert route.skip_request is False + assert route.responses == {} + assert route.response_model is None + assert route.request_model is None + + def test_route_with_params(self): + route = Route( + method="GET", + url="https://example.com/search", + handler=dummy_handler, + params={"q": "test", "page": "1"}, + ) + assert route.params == {"q": "test", "page": "1"} + + def test_route_with_json(self): + route = Route( + method="POST", + url="https://example.com/api", + handler=dummy_handler, + json={"name": "test", "value": 42}, + ) + assert route.json == {"name": "test", "value": 42} + + def test_route_with_data(self): + route = Route( + method="POST", + url="https://example.com/upload", + handler=dummy_handler, + data=b"binary content", + ) + assert route.data == b"binary content" + + def test_route_with_tags(self): + route = Route( + method="GET", + url="https://example.com/api", + handler=dummy_handler, + tags=["users", "admin"], + ) + assert route.tags == ["users", "admin"] + + def test_route_tags_none_becomes_empty(self): + route = Route(method="GET", url="https://example.com", handler=dummy_handler, tags=None) + assert route.tags == [] + + def test_route_dependencies_none_becomes_empty(self): + route = Route(method="GET", url="https://example.com", handler=dummy_handler, dependencies=None) + assert route.dependencies == [] + + def test_route_skip_request(self): + route = Route( + method="GET", + url="https://example.com", + handler=dummy_handler, + skip_request=True, + ) + assert route.skip_request is True + + def test_route_all_http_methods(self): + for method in ["GET", "POST", "PUT", "PATCH", "DELETE"]: + route = Route(method=method, url="https://example.com", handler=dummy_handler) + assert route.method == method + + def test_route_with_response_model(self): + class UserModel(BaseModel): + name: str + age: int + + route = Route( + method="GET", + url="https://example.com/user", + handler=dummy_handler, + response_model=UserModel, + ) + assert route.response_model is UserModel + + def test_route_with_request_model(self): + class CreateUser(BaseModel): + name: str + + route = Route( + method="POST", + url="https://example.com/user", + handler=dummy_handler, + request_model=CreateUser, + ) + assert route.request_model is CreateUser + + def test_route_with_responses(self): + class Error404(BaseModel): + detail: str + + route = Route( + method="GET", + url="https://example.com/api", + handler=dummy_handler, + responses={404: {"model": Error404}}, + ) + assert 404 in route.responses + + def test_route_responses_none_becomes_empty(self): + route = Route(method="GET", url="https://example.com", handler=dummy_handler, responses=None) + assert route.responses == {} + + +# --------------------------------------------------------------------------- +# Router +# --------------------------------------------------------------------------- + +class TestRouter: + def test_router_creation_defaults(self): + router = Router() + assert router.base_url is None + assert router.prefix == "" + assert router.tags == [] + assert router.dependencies == [] + + def test_router_creation_with_params(self): + router = Router( + base_url="https://api.example.com", + prefix="/v1", + tags=["users"], + ) + assert router.base_url == "https://api.example.com" + assert router.prefix == "/v1" + assert router.tags == ["users"] + + def test_router_get_decorator(self): + router = Router(base_url="https://api.example.com") + + @router.get(url="/users") + async def get_users(resp: Response) -> list: + return [] + + assert len(router._route_defs) == 1 + assert router._route_defs[0].method == "GET" + + def test_router_post_decorator(self): + router = Router(base_url="https://api.example.com") + + @router.post(url="/users") + async def create_user(resp: Response) -> dict: + return {} + + assert router._route_defs[0].method == "POST" + + def test_router_put_decorator(self): + router = Router(base_url="https://api.example.com") + + @router.put(url="/users/1") + async def update_user(resp: Response) -> dict: + return {} + + assert router._route_defs[0].method == "PUT" + + def test_router_patch_decorator(self): + router = Router(base_url="https://api.example.com") + + @router.patch(url="/users/1") + async def partial_update(resp: Response) -> dict: + return {} + + assert router._route_defs[0].method == "PATCH" + + def test_router_delete_decorator(self): + router = Router(base_url="https://api.example.com") + + @router.delete(url="/users/1") + async def delete_user(resp: Response) -> None: + pass + + assert router._route_defs[0].method == "DELETE" + + def test_router_multiple_routes(self): + router = Router(base_url="https://api.example.com") + + @router.get(url="/users") + async def get_users(resp: Response) -> list: + return [] + + @router.post(url="/users") + async def create_user(resp: Response) -> dict: + return {} + + @router.delete(url="/users/1") + async def delete_user(resp: Response) -> None: + pass + + assert len(router._route_defs) == 3 + + def test_router_include_router(self): + parent = Router(base_url="https://api.example.com", prefix="/v1") + child = Router() + + parent.include_router(child, prefix="/admin") + assert len(parent._include_defs) == 1 + assert parent._include_defs[0].prefix == "/admin" + + def test_router_include_router_with_tags(self): + parent = Router(base_url="https://api.example.com") + child = Router() + + parent.include_router(child, tags=["admin"]) + assert parent._include_defs[0].tags == ["admin"] + + def test_router_include_router_with_base_url(self): + parent = Router() + child = Router() + + parent.include_router(child, base_url="https://other.example.com") + assert parent._include_defs[0].base_url == "https://other.example.com" + + def test_router_tags_none_becomes_empty(self): + router = Router(tags=None) + assert router.tags == [] + + def test_router_dependencies_none_becomes_empty(self): + router = Router(dependencies=None) + assert router.dependencies == [] + + def test_router_route_inherits_tags(self): + router = Router(base_url="https://api.example.com", tags=["shared"]) + + @router.get(url="/users") + async def get_users(resp: Response) -> list: + return [] + + assert router._route_defs[0].tags == [] + + def test_router_route_with_params(self): + router = Router(base_url="https://api.example.com") + + @router.get(url="/search", params={"limit": "10"}) + async def search(resp: Response) -> list: + return [] + + assert router._route_defs[0].params == {"limit": "10"} + + def test_router_route_with_json(self): + router = Router(base_url="https://api.example.com") + + @router.post(url="/users", json={"role": "admin"}) + async def create(resp: Response) -> dict: + return {} + + assert router._route_defs[0].json == {"role": "admin"} + + +# --------------------------------------------------------------------------- +# FastHTTP include_router integration +# --------------------------------------------------------------------------- + +class TestFastHTTPIncludeRouter: + def test_include_router_adds_routes(self): + from fasthttp import FastHTTP + + router = Router(base_url="https://api.example.com") + + @router.get(url="/users") + async def get_users(resp: Response) -> list: + return [] + + app = FastHTTP() + app.include_router(router) + assert any(r.url for r in app.routes) + + def test_include_router_with_prefix(self): + from fasthttp import FastHTTP + + router = Router(base_url="https://api.example.com") + + @router.get(url="/items") + async def get_items(resp: Response) -> list: + return [] + + app = FastHTTP() + app.include_router(router, prefix="/v2") + assert len(app.routes) >= 1 + + def test_nested_routers_included(self): + from fasthttp import FastHTTP + + child = Router(base_url="https://api.example.com") + + @child.get(url="/child-route") + async def child_handler(resp: Response) -> dict: + return {} + + parent = Router(base_url="https://api.example.com") + parent.include_router(child) + + app = FastHTTP() + app.include_router(parent) + assert len(app.routes) >= 1 diff --git a/tests/test_status.py b/tests/test_status.py new file mode 100644 index 0000000..0c6aece --- /dev/null +++ b/tests/test_status.py @@ -0,0 +1,164 @@ +"""Tests for HTTP status code constants.""" +import pytest +from fasthttp import status + + +class TestInformationalCodes: + def test_100_continue(self): + assert status.HTTP_100_CONTINUE == 100 + + def test_101_switching_protocols(self): + assert status.HTTP_101_SWITCHING_PROTOCOLS == 101 + + def test_102_processing(self): + assert status.HTTP_102_PROCESSING == 102 + + def test_103_early_hints(self): + assert status.HTTP_103_EARLY_HINTS == 103 + + +class TestSuccessCodes: + def test_200_ok(self): + assert status.HTTP_200_OK == 200 + + def test_201_created(self): + assert status.HTTP_201_CREATED == 201 + + def test_202_accepted(self): + assert status.HTTP_202_ACCEPTED == 202 + + def test_204_no_content(self): + assert status.HTTP_204_NO_CONTENT == 204 + + def test_206_partial_content(self): + assert status.HTTP_206_PARTIAL_CONTENT == 206 + + +class TestRedirectCodes: + def test_301_moved_permanently(self): + assert status.HTTP_301_MOVED_PERMANENTLY == 301 + + def test_302_found(self): + assert status.HTTP_302_FOUND == 302 + + def test_304_not_modified(self): + assert status.HTTP_304_NOT_MODIFIED == 304 + + def test_307_temporary_redirect(self): + assert status.HTTP_307_TEMPORARY_REDIRECT == 307 + + def test_308_permanent_redirect(self): + assert status.HTTP_308_PERMANENT_REDIRECT == 308 + + +class TestClientErrorCodes: + def test_400_bad_request(self): + assert status.HTTP_400_BAD_REQUEST == 400 + + def test_401_unauthorized(self): + assert status.HTTP_401_UNAUTHORIZED == 401 + + def test_403_forbidden(self): + assert status.HTTP_403_FORBIDDEN == 403 + + def test_404_not_found(self): + assert status.HTTP_404_NOT_FOUND == 404 + + def test_405_method_not_allowed(self): + assert status.HTTP_405_METHOD_NOT_ALLOWED == 405 + + def test_408_request_timeout(self): + assert status.HTTP_408_REQUEST_TIMEOUT == 408 + + def test_409_conflict(self): + assert status.HTTP_409_CONFLICT == 409 + + def test_410_gone(self): + assert status.HTTP_410_GONE == 410 + + def test_418_im_a_teapot(self): + assert status.HTTP_418_IM_A_TEAPOT == 418 + + def test_422_unprocessable_content(self): + assert status.HTTP_422_UNPROCESSABLE_CONTENT == 422 + + def test_429_too_many_requests(self): + assert status.HTTP_429_TOO_MANY_REQUESTS == 429 + + +class TestServerErrorCodes: + def test_500_internal_server_error(self): + assert status.HTTP_500_INTERNAL_SERVER_ERROR == 500 + + def test_501_not_implemented(self): + assert status.HTTP_501_NOT_IMPLEMENTED == 501 + + def test_502_bad_gateway(self): + assert status.HTTP_502_BAD_GATEWAY == 502 + + def test_503_service_unavailable(self): + assert status.HTTP_503_SERVICE_UNAVAILABLE == 503 + + def test_504_gateway_timeout(self): + assert status.HTTP_504_GATEWAY_TIMEOUT == 504 + + +class TestStatusCodeValues: + def test_1xx_are_informational(self): + codes_1xx = [ + status.HTTP_100_CONTINUE, + status.HTTP_101_SWITCHING_PROTOCOLS, + status.HTTP_102_PROCESSING, + status.HTTP_103_EARLY_HINTS, + ] + assert all(100 <= c < 200 for c in codes_1xx) + + def test_2xx_are_success(self): + codes_2xx = [ + status.HTTP_200_OK, + status.HTTP_201_CREATED, + status.HTTP_202_ACCEPTED, + status.HTTP_204_NO_CONTENT, + ] + assert all(200 <= c < 300 for c in codes_2xx) + + def test_3xx_are_redirect(self): + codes_3xx = [ + status.HTTP_301_MOVED_PERMANENTLY, + status.HTTP_302_FOUND, + status.HTTP_307_TEMPORARY_REDIRECT, + ] + assert all(300 <= c < 400 for c in codes_3xx) + + def test_4xx_are_client_errors(self): + codes_4xx = [ + status.HTTP_400_BAD_REQUEST, + status.HTTP_401_UNAUTHORIZED, + status.HTTP_404_NOT_FOUND, + ] + assert all(400 <= c < 500 for c in codes_4xx) + + def test_5xx_are_server_errors(self): + codes_5xx = [ + status.HTTP_500_INTERNAL_SERVER_ERROR, + status.HTTP_502_BAD_GATEWAY, + status.HTTP_503_SERVICE_UNAVAILABLE, + ] + assert all(500 <= c < 600 for c in codes_5xx) + + def test_all_codes_are_integers(self): + import fasthttp.status as s + for name in s.__all__: + val = getattr(s, name) + assert isinstance(val, int), f"{name} is not int" + + def test_all_codes_unique(self): + import fasthttp.status as s + values = [getattr(s, name) for name in s.__all__] + assert len(values) == len(set(values)), "Duplicate status codes found" + + def test_all_codes_in_valid_range(self): + import fasthttp.status as s + for name in s.__all__: + val = getattr(s, name) + assert 100 <= val < 600, f"{name}={val} out of range" diff --git a/uv.lock b/uv.lock index 5289444..4ea97cf 100644 --- a/uv.lock +++ b/uv.lock @@ -593,7 +593,7 @@ wheels = [ [[package]] name = "fasthttp-client" -version = "1.2.4" +version = "1.2.5" source = { editable = "." } dependencies = [ { name = "annotated-doc" },