mirror of
https://github.com/kennethreitz/responder.git
synced 2026-06-05 23:00:17 +00:00
Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b678542192 | |||
| 079e7ae3e5 | |||
| 9d4a9d5083 | |||
| 79c32fcef4 | |||
| 52896c6040 | |||
| def071fc71 | |||
| f194efecae | |||
| 868f84fc8b | |||
| ea3b6a6e4a | |||
| 0b16558b04 | |||
| 2440d4caed | |||
| e653a9f1fd | |||
| c1fe6e11bd | |||
| 06b7bae7c0 | |||
| 2494034111 | |||
| 07cfa66e5c | |||
| a5d38cf9c3 | |||
| 2f5e46e233 | |||
| c1d789f279 | |||
| 691f6b4d5c | |||
| 90a082a0ac | |||
| 5ee0de6458 | |||
| 1c729c8542 |
@@ -19,24 +19,16 @@ jobs:
|
|||||||
documentation:
|
documentation:
|
||||||
name: "Documentation"
|
name: "Documentation"
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
|
||||||
UV_SYSTEM_PYTHON: true
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Set up Python
|
- name: Install uv
|
||||||
uses: actions/setup-python@v5
|
uses: astral-sh/setup-uv@v7
|
||||||
with:
|
with:
|
||||||
python-version: "3.13"
|
activate-environment: true
|
||||||
|
|
||||||
- name: Set up uv
|
|
||||||
uses: astral-sh/setup-uv@v5
|
|
||||||
with:
|
|
||||||
version: "latest"
|
|
||||||
enable-cache: true
|
enable-cache: true
|
||||||
cache-dependency-glob: |
|
python-version: "3.14"
|
||||||
pyproject.toml
|
|
||||||
|
|
||||||
- name: Install package and documentation dependencies
|
- name: Install package and documentation dependencies
|
||||||
run: uv pip install '.[docs]'
|
run: uv pip install '.[docs]'
|
||||||
|
|||||||
@@ -28,28 +28,17 @@ jobs:
|
|||||||
"3.14t",
|
"3.14t",
|
||||||
"pypy3.11",
|
"pypy3.11",
|
||||||
]
|
]
|
||||||
env:
|
|
||||||
UV_SYSTEM_PYTHON: true
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 22
|
|
||||||
|
|
||||||
- name: Set up Python
|
- name: Install uv
|
||||||
uses: actions/setup-python@v5
|
uses: astral-sh/setup-uv@v7
|
||||||
with:
|
with:
|
||||||
python-version: ${{ matrix.python-version }}
|
activate-environment: true
|
||||||
|
|
||||||
- name: Set up uv
|
|
||||||
uses: astral-sh/setup-uv@v5
|
|
||||||
with:
|
|
||||||
version: "latest"
|
|
||||||
enable-cache: true
|
enable-cache: true
|
||||||
cache-suffix: ${{ matrix.python-version }}
|
cache-suffix: ${{ matrix.python-version }}
|
||||||
cache-dependency-glob: |
|
python-version: ${{ matrix.python-version }}
|
||||||
pyproject.toml
|
|
||||||
|
|
||||||
- name: Install package
|
- name: Install package
|
||||||
run: uv pip install '.[develop,test]'
|
run: uv pip install '.[develop,test]'
|
||||||
|
|||||||
@@ -5,6 +5,72 @@ All notable changes to this project will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and
|
||||||
this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [v3.6.2] - 2026-04-12
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- GraphQL error responses now correctly return 400 status instead of always 200
|
||||||
|
- OpenAPI docs UI now respects custom `openapi_route` instead of hardcoding `/schema.yml`
|
||||||
|
- `before_requests` default type mismatch that could crash routes called outside the router
|
||||||
|
- Blocking synchronous file I/O in `Response.stream_file()` — now uses async I/O via anyio
|
||||||
|
- Memory leak in rate limiter (empty bucket keys never cleaned up)
|
||||||
|
- Race condition in rate limiter `check()` — added thread-safe locking
|
||||||
|
- WSGI fallback catching all `TypeError`s instead of just call-signature mismatches
|
||||||
|
- Pydantic request/response model validation crashing on non-dict bodies
|
||||||
|
- Test assertions that could never fail (`or True`, `< 500` patterns)
|
||||||
|
- `CaseInsensitiveDict` missing `__delitem__`, `pop`, and `setdefault` overrides
|
||||||
|
- `assert` used for input validation in OpenAPI extension (stripped by `python -O`)
|
||||||
|
- Potential XSS in GraphiQL template endpoint injection
|
||||||
|
- Dead `or ""` in media format detection logic
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- `DELETE` requests now participate in Pydantic request body validation
|
||||||
|
- Simplified status code category check to use chained comparison
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- Unused `method` parameter from `load_target()`
|
||||||
|
- Unused Node.js setup step from CI test workflow
|
||||||
|
|
||||||
|
## [v3.6.1] - 2026-04-12
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Configurable GZip compression via `gzip` parameter on `API()` (defaults to `True`)
|
||||||
|
|
||||||
|
## [v3.6.0] - 2026-03-24
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Built-in structured logging with per-request context (`enable_logging=True`)
|
||||||
|
- `api.log` — always-available logger, enriched with request context when logging is enabled
|
||||||
|
- Automatic access logging with timing: `GET /path → 200 (1.2ms)`
|
||||||
|
- Request ID generation/forwarding via `X-Request-ID` header
|
||||||
|
- `contextvars`-based request context (ID, method, path, client IP) on every log record
|
||||||
|
- `responder.ext.logging` module: `get_logger()`, `RequestContext`, `RequestContextFilter`
|
||||||
|
- CLAUDE.md project guide and `/release` command
|
||||||
|
- Version number in docs sidebar
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Comprehensive documentation improvements across all pages
|
||||||
|
- Deployment: health checks, Docker Compose, Caddy, Procfile, production checklist
|
||||||
|
- API reference: usage examples for every class
|
||||||
|
- Feature tour: Pydantic validation, content negotiation, structured logging sections
|
||||||
|
- Tutorials: modernized SQLAlchemy to `mapped_column()`, fixed deprecated `datetime.utcnow()`,
|
||||||
|
WebSocket `WebSocketDisconnect` handling, role-based auth, auth strategy guide
|
||||||
|
- Testing: rate limiting and WSGI mount examples
|
||||||
|
- Middleware: pure ASGI middleware example
|
||||||
|
- Quickstart: links to all tutorials
|
||||||
|
- Sandbox: full rewrite with project layout
|
||||||
|
- Docker example uses `uv` instead of pip
|
||||||
|
- Backlog updated: removed implemented features, replaced HTTP/2 server push with dependency injection
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- `uv.lock` — this is a library, not an application
|
||||||
|
|
||||||
## [v3.5.0] - 2026-03-24
|
## [v3.5.0] - 2026-03-24
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -459,6 +525,9 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
|
|||||||
|
|
||||||
- Conception!
|
- Conception!
|
||||||
|
|
||||||
|
[v3.6.2]: https://github.com/kennethreitz/responder/compare/v3.6.1..v3.6.2
|
||||||
|
[v3.6.1]: https://github.com/kennethreitz/responder/compare/v3.6.0..v3.6.1
|
||||||
|
[v3.6.0]: https://github.com/kennethreitz/responder/compare/v3.5.0..v3.6.0
|
||||||
[v3.5.0]: https://github.com/kennethreitz/responder/compare/v3.4.0..v3.5.0
|
[v3.5.0]: https://github.com/kennethreitz/responder/compare/v3.4.0..v3.5.0
|
||||||
[v3.4.0]: https://github.com/kennethreitz/responder/compare/v3.3.0..v3.4.0
|
[v3.4.0]: https://github.com/kennethreitz/responder/compare/v3.3.0..v3.4.0
|
||||||
[v3.3.0]: https://github.com/kennethreitz/responder/compare/v3.2.0..v3.3.0
|
[v3.3.0]: https://github.com/kennethreitz/responder/compare/v3.2.0..v3.3.0
|
||||||
|
|||||||
@@ -5,4 +5,4 @@
|
|||||||
- Per-route rate limiting (different limits for different endpoints)
|
- Per-route rate limiting (different limits for different endpoints)
|
||||||
- Built-in structured logging with request context
|
- Built-in structured logging with request context
|
||||||
- OpenAPI 3.1 support
|
- OpenAPI 3.1 support
|
||||||
- HTTP/2 server push
|
- Dependency injection for route handlers
|
||||||
|
|||||||
@@ -30,8 +30,9 @@ Here's a minimal Dockerfile::
|
|||||||
|
|
||||||
FROM python:3.13-slim
|
FROM python:3.13-slim
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN pip install responder
|
RUN uv pip install --system responder
|
||||||
ENV PORT=80
|
ENV PORT=80
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
CMD ["python", "api.py"]
|
CMD ["python", "api.py"]
|
||||||
@@ -42,9 +43,10 @@ Build and run::
|
|||||||
$ docker run -p 8000:80 myapi
|
$ docker run -p 8000:80 myapi
|
||||||
|
|
||||||
The ``python:3.13-slim`` image is about 150MB — small enough for fast
|
The ``python:3.13-slim`` image is about 150MB — small enough for fast
|
||||||
deploys but includes everything you need. For even smaller images, you
|
deploys but includes everything you need. Using ``uv`` for installs
|
||||||
can use ``python:3.13-alpine``, though some packages may need extra
|
is significantly faster than pip. For even smaller images, you can use
|
||||||
build dependencies.
|
``python:3.13-alpine``, though some packages may need extra build
|
||||||
|
dependencies.
|
||||||
|
|
||||||
|
|
||||||
Cloud Platforms
|
Cloud Platforms
|
||||||
@@ -182,4 +184,4 @@ Before going live:
|
|||||||
- **Add a health check** — ``/health`` endpoint for monitoring
|
- **Add a health check** — ``/health`` endpoint for monitoring
|
||||||
- **Enable HTTPS** — via your proxy, cloud platform, or uvicorn's ``--ssl-*`` flags
|
- **Enable HTTPS** — via your proxy, cloud platform, or uvicorn's ``--ssl-*`` flags
|
||||||
- **Set up logging** — uvicorn logs requests by default; pipe them to your log aggregator
|
- **Set up logging** — uvicorn logs requests by default; pipe them to your log aggregator
|
||||||
- **Pin your dependencies** — commit ``uv.lock`` for reproducible deploys
|
- **Pin your dependencies** — use a lock file or pinned requirements for reproducible deploys
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ One ``pip install``, batteries included:
|
|||||||
- Sync and async views — ``async`` is always optional.
|
- Sync and async views — ``async`` is always optional.
|
||||||
- Class-based views with ``on_get``, ``on_post``, ``on_request``.
|
- Class-based views with ``on_get``, ``on_post``, ``on_request``.
|
||||||
- Built-in rate limiting with ``X-RateLimit`` headers.
|
- Built-in rate limiting with ``X-RateLimit`` headers.
|
||||||
|
- Structured logging with per-request context.
|
||||||
- Content negotiation: JSON, YAML, and MessagePack.
|
- Content negotiation: JSON, YAML, and MessagePack.
|
||||||
- A pleasant API with a single import statement.
|
- A pleasant API with a single import statement.
|
||||||
- OpenAPI schema generation with Swagger UI.
|
- OpenAPI schema generation with Swagger UI.
|
||||||
|
|||||||
@@ -376,3 +376,9 @@ jump into the tutorials:
|
|||||||
- :doc:`tutorial-rest` — build a full CRUD API with validation
|
- :doc:`tutorial-rest` — build a full CRUD API with validation
|
||||||
- :doc:`tutorial-sqlalchemy` — connect to a database
|
- :doc:`tutorial-sqlalchemy` — connect to a database
|
||||||
- :doc:`tutorial-auth` — add authentication
|
- :doc:`tutorial-auth` — add authentication
|
||||||
|
- :doc:`tutorial-websockets` — real-time communication
|
||||||
|
- :doc:`tutorial-middleware` — hooks and middleware
|
||||||
|
- :doc:`tutorial-flask` — migrating from Flask
|
||||||
|
- :doc:`guide-config` — environment variables and secrets
|
||||||
|
- :doc:`deployment` — Docker, cloud platforms, and production
|
||||||
|
- :doc:`testing` — writing tests with pytest
|
||||||
|
|||||||
+43
-17
@@ -2,35 +2,61 @@
|
|||||||
# Development Sandbox
|
# Development Sandbox
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
Set up a development sandbox.
|
|
||||||
|
|
||||||
Acquire sources and create virtualenv.
|
Clone the repo and install all dependencies:
|
||||||
```shell
|
```shell
|
||||||
git clone https://github.com/kennethreitz/responder.git
|
git clone https://github.com/kennethreitz/responder.git
|
||||||
cd responder
|
cd responder
|
||||||
uv venv
|
uv venv && source .venv/bin/activate
|
||||||
```
|
|
||||||
|
|
||||||
Install project in editable mode, including
|
|
||||||
all development tools.
|
|
||||||
```shell
|
|
||||||
uv pip install --upgrade --editable '.[develop,docs,release,test]'
|
uv pip install --upgrade --editable '.[develop,docs,release,test]'
|
||||||
```
|
```
|
||||||
|
|
||||||
## Operations
|
## Running Tests
|
||||||
Run tests.
|
|
||||||
```shell
|
```shell
|
||||||
source .venv/bin/activate
|
pytest # full suite with coverage
|
||||||
pytest
|
pytest tests/test_responder.py -xvs # single file, stop on first failure
|
||||||
|
pytest -k "test_mount" # run tests matching a pattern
|
||||||
```
|
```
|
||||||
|
|
||||||
Format code.
|
## Code Formatting
|
||||||
```shell
|
```shell
|
||||||
ruff format .
|
ruff format . # auto-format
|
||||||
ruff check --fix .
|
ruff check --fix . # lint and auto-fix
|
||||||
```
|
```
|
||||||
|
|
||||||
Documentation authoring.
|
## Type Checking
|
||||||
```shell
|
```shell
|
||||||
sphinx-autobuild --open-browser --watch docs/source docs/source docs/build
|
mypy
|
||||||
|
```
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
Live-reloading doc server (opens in browser):
|
||||||
|
```shell
|
||||||
|
cd docs
|
||||||
|
sphinx-autobuild --open-browser --watch source source build
|
||||||
|
```
|
||||||
|
|
||||||
|
Or build once:
|
||||||
|
```shell
|
||||||
|
cd docs
|
||||||
|
make html
|
||||||
|
# open build/html/index.html
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
responder/
|
||||||
|
├── responder/ # main package
|
||||||
|
│ ├── api.py # API class — the entry point
|
||||||
|
│ ├── routes.py # Router, Route, WebSocketRoute
|
||||||
|
│ ├── models.py # Request and Response wrappers
|
||||||
|
│ ├── ext/ # extensions (CLI, GraphQL, OpenAPI, rate limiting)
|
||||||
|
│ ├── background.py # background task queue
|
||||||
|
│ └── formats.py # content negotiation (JSON, YAML, msgpack)
|
||||||
|
├── tests/ # pytest test suite
|
||||||
|
├── examples/ # runnable example apps
|
||||||
|
├── docs/source/ # Sphinx documentation
|
||||||
|
└── pyproject.toml # project metadata and tool config
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -596,6 +596,60 @@ can pace themselves.
|
|||||||
The rate limiter is per-client, keyed by IP address.
|
The rate limiter is per-client, keyed by IP address.
|
||||||
|
|
||||||
|
|
||||||
|
Structured Logging
|
||||||
|
------------------
|
||||||
|
|
||||||
|
Production applications need structured, searchable logs. Responder
|
||||||
|
includes built-in logging that automatically attaches request context
|
||||||
|
— request ID, HTTP method, path, and client IP — to every log message
|
||||||
|
emitted during request handling::
|
||||||
|
|
||||||
|
api = responder.API(enable_logging=True)
|
||||||
|
|
||||||
|
This gives you:
|
||||||
|
|
||||||
|
- **Access logging** with timing for every request::
|
||||||
|
|
||||||
|
2026-03-24 12:00:00 [INFO] responder.access — GET /users → 200 (1.2ms)
|
||||||
|
|
||||||
|
- **A logger on the API instance** — use ``api.log`` anywhere in
|
||||||
|
your routes. Request context (ID, method, path, client IP) is
|
||||||
|
attached automatically::
|
||||||
|
|
||||||
|
@api.route("/users/{user_id:int}")
|
||||||
|
def get_user(req, resp, *, user_id):
|
||||||
|
api.log.info("fetching user %d", user_id)
|
||||||
|
# => [INFO] responder.app -- fetching user 42 [GET /users/42] [req:a1b2c3] [client:10.0.0.1]
|
||||||
|
resp.media = {"id": user_id}
|
||||||
|
|
||||||
|
- **Request IDs** generated automatically (or forwarded from the
|
||||||
|
``X-Request-ID`` header) and included in responses.
|
||||||
|
|
||||||
|
The logging uses Python's standard ``logging`` module, so it works with
|
||||||
|
any handler — files, syslog, JSON formatters, Datadog, Sentry, whatever
|
||||||
|
you already use.
|
||||||
|
|
||||||
|
For additional loggers (e.g. in helper modules), use ``get_logger``::
|
||||||
|
|
||||||
|
from responder.ext.logging import get_logger
|
||||||
|
logger = get_logger("myapp.db")
|
||||||
|
|
||||||
|
You can also access the current request context directly::
|
||||||
|
|
||||||
|
from responder.ext.logging import RequestContext
|
||||||
|
|
||||||
|
@api.route("/debug")
|
||||||
|
def debug(req, resp):
|
||||||
|
resp.media = {
|
||||||
|
"request_id": RequestContext.get_request_id(),
|
||||||
|
"client_ip": RequestContext.get_client_ip(),
|
||||||
|
}
|
||||||
|
|
||||||
|
When ``enable_logging=True`` is set, it supersedes ``request_id=True``
|
||||||
|
— the logging middleware handles request IDs itself, so you don't get
|
||||||
|
duplicate headers.
|
||||||
|
|
||||||
|
|
||||||
Pydantic Validation
|
Pydantic Validation
|
||||||
-------------------
|
-------------------
|
||||||
|
|
||||||
|
|||||||
@@ -46,14 +46,14 @@ Install PyJWT::
|
|||||||
Create a helper to encode and decode tokens::
|
Create a helper to encode and decode tokens::
|
||||||
|
|
||||||
import jwt
|
import jwt
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
SECRET = "your-secret-key"
|
SECRET = "your-secret-key"
|
||||||
|
|
||||||
def create_token(user_id: int) -> str:
|
def create_token(user_id: int) -> str:
|
||||||
payload = {
|
payload = {
|
||||||
"sub": user_id,
|
"sub": user_id,
|
||||||
"exp": datetime.utcnow() + timedelta(hours=24),
|
"exp": datetime.now(timezone.utc) + timedelta(hours=24),
|
||||||
}
|
}
|
||||||
return jwt.encode(payload, SECRET, algorithm="HS256")
|
return jwt.encode(payload, SECRET, algorithm="HS256")
|
||||||
|
|
||||||
@@ -189,3 +189,56 @@ Remember to set a proper secret key::
|
|||||||
The session data is signed (not encrypted) — users can read it but
|
The session data is signed (not encrypted) — users can read it but
|
||||||
can't tamper with it. Don't store sensitive data like passwords in
|
can't tamper with it. Don't store sensitive data like passwords in
|
||||||
sessions.
|
sessions.
|
||||||
|
|
||||||
|
|
||||||
|
Role-Based Access Control
|
||||||
|
--------------------------
|
||||||
|
|
||||||
|
For APIs where different users have different permissions, embed the
|
||||||
|
role in the token and check it in route-specific guards::
|
||||||
|
|
||||||
|
def create_token(user_id: int, role: str = "user") -> str:
|
||||||
|
payload = {
|
||||||
|
"sub": user_id,
|
||||||
|
"role": role,
|
||||||
|
"exp": datetime.now(timezone.utc) + timedelta(hours=24),
|
||||||
|
}
|
||||||
|
return jwt.encode(payload, SECRET, algorithm="HS256")
|
||||||
|
|
||||||
|
Create a helper that checks for a specific role::
|
||||||
|
|
||||||
|
def require_role(*roles):
|
||||||
|
"""Before-request hook factory that restricts by role."""
|
||||||
|
def check(req, resp):
|
||||||
|
user_role = getattr(req.state, "role", None)
|
||||||
|
if user_role not in roles:
|
||||||
|
resp.status_code = 403
|
||||||
|
resp.media = {"error": "Insufficient permissions"}
|
||||||
|
return check
|
||||||
|
|
||||||
|
Use it on specific routes::
|
||||||
|
|
||||||
|
@api.route("/admin/users", before_request=require_role("admin"))
|
||||||
|
def list_all_users(req, resp):
|
||||||
|
resp.media = {"users": [...]}
|
||||||
|
|
||||||
|
And store the role during token verification::
|
||||||
|
|
||||||
|
# In your auth_guard:
|
||||||
|
req.state.user_id = payload["sub"]
|
||||||
|
req.state.role = payload.get("role", "user")
|
||||||
|
|
||||||
|
|
||||||
|
Choosing an Auth Strategy
|
||||||
|
--------------------------
|
||||||
|
|
||||||
|
- **API keys** — simplest. Good for server-to-server, CLI tools, and
|
||||||
|
internal services. No expiration unless you build it.
|
||||||
|
- **JWT tokens** — standard for SPAs and mobile apps. Stateless, so
|
||||||
|
they scale well. Downside: you can't revoke them without a blocklist.
|
||||||
|
- **Sessions** — best for traditional web apps with HTML forms. The
|
||||||
|
browser manages cookies automatically. Stateful — the server controls
|
||||||
|
the session lifecycle.
|
||||||
|
|
||||||
|
Start with API keys for internal tools, JWT for public APIs, and
|
||||||
|
sessions for web apps with login pages.
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ SQLAlchemy models map Python classes to database tables. Each attribute
|
|||||||
becomes a column::
|
becomes a column::
|
||||||
|
|
||||||
# models.py
|
# models.py
|
||||||
from sqlalchemy import Column, Integer, String
|
from sqlalchemy import String
|
||||||
from sqlalchemy.orm import DeclarativeBase
|
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||||
|
|
||||||
class Base(DeclarativeBase):
|
class Base(DeclarativeBase):
|
||||||
pass
|
pass
|
||||||
@@ -37,15 +37,16 @@ becomes a column::
|
|||||||
class Book(Base):
|
class Book(Base):
|
||||||
__tablename__ = "books"
|
__tablename__ = "books"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
title = Column(String, nullable=False)
|
title: Mapped[str] = mapped_column(String, nullable=False)
|
||||||
author = Column(String, nullable=False)
|
author: Mapped[str] = mapped_column(String, nullable=False)
|
||||||
year = Column(Integer, nullable=False)
|
year: Mapped[int] = mapped_column(nullable=False)
|
||||||
isbn = Column(String, nullable=True)
|
isbn: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||||
|
|
||||||
``DeclarativeBase`` is SQLAlchemy's modern base class (SQLAlchemy 2.0+).
|
This uses SQLAlchemy 2.0's ``Mapped`` type annotations and
|
||||||
Each model class corresponds to a table, and each ``Column`` corresponds
|
``mapped_column()``, which give you type checker support and cleaner
|
||||||
to a column in that table.
|
syntax than the older ``Column()`` style. Each model class corresponds
|
||||||
|
to a table, and each ``mapped_column()`` corresponds to a column.
|
||||||
|
|
||||||
|
|
||||||
Database Setup
|
Database Setup
|
||||||
|
|||||||
@@ -58,6 +58,8 @@ A chat room needs to broadcast messages to all connected clients. We keep
|
|||||||
a set of active connections and iterate through them when someone sends
|
a set of active connections and iterate through them when someone sends
|
||||||
a message::
|
a message::
|
||||||
|
|
||||||
|
from starlette.websockets import WebSocketDisconnect
|
||||||
|
|
||||||
connected = set()
|
connected = set()
|
||||||
|
|
||||||
@api.route("/chat", websocket=True)
|
@api.route("/chat", websocket=True)
|
||||||
@@ -70,13 +72,15 @@ a message::
|
|||||||
# Broadcast to all connected clients
|
# Broadcast to all connected clients
|
||||||
for client in connected:
|
for client in connected:
|
||||||
await client.send_text(message)
|
await client.send_text(message)
|
||||||
except Exception:
|
except WebSocketDisconnect:
|
||||||
pass
|
pass
|
||||||
finally:
|
finally:
|
||||||
connected.discard(ws)
|
connected.discard(ws)
|
||||||
|
|
||||||
The ``try/finally`` block ensures we remove disconnected clients from
|
The ``try/finally`` block ensures we remove disconnected clients from
|
||||||
the set, even if the connection drops unexpectedly.
|
the set, even if the connection drops unexpectedly. Catching
|
||||||
|
``WebSocketDisconnect`` specifically (rather than bare ``Exception``)
|
||||||
|
makes the intent clear and avoids swallowing real bugs.
|
||||||
|
|
||||||
|
|
||||||
Data Formats
|
Data Formats
|
||||||
@@ -154,6 +158,40 @@ WebSocket before-request hooks receive the ``ws`` object and must call
|
|||||||
``await ws.accept()`` if they want the connection to proceed.
|
``await ws.accept()`` if they want the connection to proceed.
|
||||||
|
|
||||||
|
|
||||||
|
Connection Lifecycle
|
||||||
|
--------------------
|
||||||
|
|
||||||
|
WebSocket connections go through several states:
|
||||||
|
|
||||||
|
1. **Connecting** — the client sends an upgrade request
|
||||||
|
2. **Open** — after ``await ws.accept()``, both sides can send messages
|
||||||
|
3. **Closing** — either side initiates a close handshake
|
||||||
|
4. **Closed** — the connection is fully terminated
|
||||||
|
|
||||||
|
When a client disconnects (closes the tab, loses network), the next
|
||||||
|
``await ws.receive_text()`` raises ``WebSocketDisconnect``. Always
|
||||||
|
handle this — otherwise your server accumulates dead connections::
|
||||||
|
|
||||||
|
from starlette.websockets import WebSocketDisconnect
|
||||||
|
|
||||||
|
@api.route("/ws", websocket=True)
|
||||||
|
async def handler(ws):
|
||||||
|
await ws.accept()
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
data = await ws.receive_text()
|
||||||
|
await ws.send_text(f"Got: {data}")
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
print("Client disconnected")
|
||||||
|
|
||||||
|
You can also close connections from the server side::
|
||||||
|
|
||||||
|
await ws.close(code=1000) # 1000 = normal closure
|
||||||
|
|
||||||
|
Common close codes: ``1000`` (normal), ``1001`` (going away),
|
||||||
|
``1008`` (policy violation), ``1011`` (server error).
|
||||||
|
|
||||||
|
|
||||||
Testing WebSockets
|
Testing WebSockets
|
||||||
------------------
|
------------------
|
||||||
|
|
||||||
@@ -169,3 +207,13 @@ Use Starlette's ``TestClient`` for WebSocket tests::
|
|||||||
|
|
||||||
The ``websocket_connect`` context manager handles the connection
|
The ``websocket_connect`` context manager handles the connection
|
||||||
lifecycle — it connects on enter and disconnects on exit.
|
lifecycle — it connects on enter and disconnects on exit.
|
||||||
|
|
||||||
|
You can also test that connections are properly rejected::
|
||||||
|
|
||||||
|
from starlette.websockets import WebSocketDisconnect
|
||||||
|
|
||||||
|
def test_websocket_404():
|
||||||
|
client = TestClient(api)
|
||||||
|
with pytest.raises(WebSocketDisconnect):
|
||||||
|
with client.websocket_connect("/nonexistent"):
|
||||||
|
pass
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
__version__ = "3.5.0"
|
__version__ = "3.6.2"
|
||||||
|
|||||||
+23
-2
@@ -31,6 +31,7 @@ class API:
|
|||||||
:param templates_dir: The directory to use for templates. Will be created for you if it doesn't already exist.
|
:param templates_dir: The directory to use for templates. Will be created for you if it doesn't already exist.
|
||||||
:param auto_escape: If ``True``, HTML and XML templates will automatically be escaped.
|
:param auto_escape: If ``True``, HTML and XML templates will automatically be escaped.
|
||||||
:param enable_hsts: If ``True``, send all responses to HTTPS URLs.
|
:param enable_hsts: If ``True``, send all responses to HTTPS URLs.
|
||||||
|
:param gzip: If ``True`` (the default), compress responses with GZip.
|
||||||
:param openapi_theme: OpenAPI documentation theme, must be one of ``elements``, ``rapidoc``, ``redoc``, ``swagger_ui``
|
:param openapi_theme: OpenAPI documentation theme, must be one of ``elements``, ``rapidoc``, ``redoc``, ``swagger_ui``
|
||||||
""" # noqa: E501
|
""" # noqa: E501
|
||||||
|
|
||||||
@@ -60,7 +61,9 @@ class API:
|
|||||||
allowed_hosts=None,
|
allowed_hosts=None,
|
||||||
openapi_theme=DEFAULT_OPENAPI_THEME,
|
openapi_theme=DEFAULT_OPENAPI_THEME,
|
||||||
lifespan=None,
|
lifespan=None,
|
||||||
|
gzip=True,
|
||||||
request_id=False,
|
request_id=False,
|
||||||
|
enable_logging=False,
|
||||||
):
|
):
|
||||||
"""Create a new Responder API instance.
|
"""Create a new Responder API instance.
|
||||||
|
|
||||||
@@ -85,7 +88,9 @@ class API:
|
|||||||
:param allowed_hosts: List of allowed hostnames (e.g. ``["example.com"]``). Defaults to ``["*"]``.
|
:param allowed_hosts: List of allowed hostnames (e.g. ``["example.com"]``). Defaults to ``["*"]``.
|
||||||
:param openapi_theme: Documentation UI theme: ``"swagger_ui"``, ``"redoc"``, ``"rapidoc"``, or ``"elements"``.
|
:param openapi_theme: Documentation UI theme: ``"swagger_ui"``, ``"redoc"``, ``"rapidoc"``, or ``"elements"``.
|
||||||
:param lifespan: An async context manager for startup/shutdown logic.
|
:param lifespan: An async context manager for startup/shutdown logic.
|
||||||
|
:param gzip: If ``True`` (the default), compress responses with GZip.
|
||||||
:param request_id: If ``True``, add ``X-Request-ID`` headers to all responses.
|
:param request_id: If ``True``, add ``X-Request-ID`` headers to all responses.
|
||||||
|
:param enable_logging: If ``True``, enable structured logging with per-request context (request ID, method, path, client IP).
|
||||||
""" # noqa: E501
|
""" # noqa: E501
|
||||||
self.background = BackgroundQueue()
|
self.background = BackgroundQueue()
|
||||||
|
|
||||||
@@ -120,7 +125,9 @@ class API:
|
|||||||
|
|
||||||
self.default_endpoint = None
|
self.default_endpoint = None
|
||||||
self.app = ExceptionMiddleware(self.router, debug=debug)
|
self.app = ExceptionMiddleware(self.router, debug=debug)
|
||||||
self.add_middleware(GZipMiddleware)
|
|
||||||
|
if gzip:
|
||||||
|
self.add_middleware(GZipMiddleware)
|
||||||
|
|
||||||
if self.hsts_enabled:
|
if self.hsts_enabled:
|
||||||
self.add_middleware(HTTPSRedirectMiddleware)
|
self.add_middleware(HTTPSRedirectMiddleware)
|
||||||
@@ -158,7 +165,7 @@ class API:
|
|||||||
|
|
||||||
self.templates = Templates(directory=templates_dir)
|
self.templates = Templates(directory=templates_dir)
|
||||||
|
|
||||||
if request_id:
|
if request_id and not enable_logging:
|
||||||
import uuid as _uuid
|
import uuid as _uuid
|
||||||
|
|
||||||
def _add_request_id(req, resp):
|
def _add_request_id(req, resp):
|
||||||
@@ -167,6 +174,20 @@ class API:
|
|||||||
|
|
||||||
self.router.after_request(_add_request_id)
|
self.router.after_request(_add_request_id)
|
||||||
|
|
||||||
|
if enable_logging:
|
||||||
|
import logging as _logging
|
||||||
|
|
||||||
|
from .ext.logging import LoggingMiddleware, get_logger, setup_logging
|
||||||
|
|
||||||
|
log_level = _logging.DEBUG if debug else _logging.INFO
|
||||||
|
setup_logging(level=log_level)
|
||||||
|
self.add_middleware(LoggingMiddleware)
|
||||||
|
self.log = get_logger("responder.app")
|
||||||
|
else:
|
||||||
|
import logging as _logging
|
||||||
|
|
||||||
|
self.log = _logging.getLogger("responder.app")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def requests(self):
|
def requests(self):
|
||||||
"""A test client connected to the ASGI app. Lazily initialized."""
|
"""A test client connected to the ASGI app. Lazily initialized."""
|
||||||
|
|||||||
@@ -101,8 +101,7 @@ class GraphQLView:
|
|||||||
response_data["data"] = result.data
|
response_data["data"] = result.data
|
||||||
|
|
||||||
resp.media = response_data
|
resp.media = response_data
|
||||||
status_code = 200 if not result.errors else 400
|
resp.status_code = 200 if not result.errors else 400
|
||||||
return (query, json.dumps(response_data), status_code)
|
|
||||||
|
|
||||||
async def on_request(self, req, resp):
|
async def on_request(self, req, resp):
|
||||||
await self.graphql_response(req, resp)
|
await self.graphql_response(req, resp)
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ GRAPHIQL = """
|
|||||||
<script crossorigin src="//cdn.jsdelivr.net/npm/react-dom@{{ REACT_VERSION }}/umd/react-dom.production.min.js"></script>
|
<script crossorigin src="//cdn.jsdelivr.net/npm/react-dom@{{ REACT_VERSION }}/umd/react-dom.production.min.js"></script>
|
||||||
<script src="//cdn.jsdelivr.net/npm/graphiql@{{ GRAPHIQL_VERSION }}/graphiql.min.js"></script>
|
<script src="//cdn.jsdelivr.net/npm/graphiql@{{ GRAPHIQL_VERSION }}/graphiql.min.js"></script>
|
||||||
<script>
|
<script>
|
||||||
const fetcher = GraphiQL.createFetcher({ url: '{{ endpoint }}' });
|
const fetcher = GraphiQL.createFetcher({ url: {{ endpoint | tojson }} });
|
||||||
const root = ReactDOM.createRoot(document.getElementById('graphiql'));
|
const root = ReactDOM.createRoot(document.getElementById('graphiql'));
|
||||||
root.render(React.createElement(GraphiQL, { fetcher: fetcher }));
|
root.render(React.createElement(GraphiQL, { fetcher: fetcher }));
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
"""Structured logging with per-request context.
|
||||||
|
|
||||||
|
Provides a logging setup that automatically includes request metadata
|
||||||
|
(request ID, method, path, client IP) in every log message emitted
|
||||||
|
during request handling.
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
api = responder.API(enable_logging=True)
|
||||||
|
|
||||||
|
# In any route or middleware:
|
||||||
|
from responder.ext.logging import get_logger
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
@api.route("/")
|
||||||
|
def index(req, resp):
|
||||||
|
logger.info("handling request")
|
||||||
|
# => 2026-03-24 12:00:00 [INFO] app — handling request [GET /] [req:abc123] [client:127.0.0.1]
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from contextvars import ContextVar
|
||||||
|
|
||||||
|
__all__ = ["get_logger", "RequestContext", "RequestContextFilter", "LoggingMiddleware"]
|
||||||
|
|
||||||
|
# Context variables for per-request metadata.
|
||||||
|
_request_id: ContextVar[str] = ContextVar("request_id", default="-")
|
||||||
|
_request_method: ContextVar[str] = ContextVar("request_method", default="-")
|
||||||
|
_request_path: ContextVar[str] = ContextVar("request_path", default="-")
|
||||||
|
_client_ip: ContextVar[str] = ContextVar("client_ip", default="-")
|
||||||
|
|
||||||
|
|
||||||
|
class RequestContext:
|
||||||
|
"""Read-only access to the current request's logging context."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_request_id() -> str:
|
||||||
|
return _request_id.get()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_method() -> str:
|
||||||
|
return _request_method.get()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_path() -> str:
|
||||||
|
return _request_path.get()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_client_ip() -> str:
|
||||||
|
return _client_ip.get()
|
||||||
|
|
||||||
|
|
||||||
|
class RequestContextFilter(logging.Filter):
|
||||||
|
"""A logging filter that injects request context into log records.
|
||||||
|
|
||||||
|
Adds ``request_id``, ``request_method``, ``request_path``, and
|
||||||
|
``client_ip`` attributes to every log record, so they can be used
|
||||||
|
in format strings.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def filter(self, record: logging.LogRecord) -> bool:
|
||||||
|
record.request_id = _request_id.get() # type: ignore[attr-defined]
|
||||||
|
record.request_method = _request_method.get() # type: ignore[attr-defined]
|
||||||
|
record.request_path = _request_path.get() # type: ignore[attr-defined]
|
||||||
|
record.client_ip = _client_ip.get() # type: ignore[attr-defined]
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
# Default format that includes request context.
|
||||||
|
DEFAULT_LOG_FORMAT = (
|
||||||
|
"%(asctime)s [%(levelname)s] %(name)s -- %(message)s "
|
||||||
|
"[%(request_method)s %(request_path)s] "
|
||||||
|
"[req:%(request_id)s] [client:%(client_ip)s]"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_logger(name: str | None = None) -> logging.Logger:
|
||||||
|
"""Get a logger with the request context filter attached.
|
||||||
|
|
||||||
|
:param name: Logger name (typically ``__name__``).
|
||||||
|
:returns: A :class:`logging.Logger` with request context available.
|
||||||
|
"""
|
||||||
|
logger = logging.getLogger(name)
|
||||||
|
# Avoid adding duplicate filters.
|
||||||
|
if not any(isinstance(f, RequestContextFilter) for f in logger.filters):
|
||||||
|
logger.addFilter(RequestContextFilter())
|
||||||
|
return logger
|
||||||
|
|
||||||
|
|
||||||
|
def setup_logging(level: int = logging.INFO) -> None:
|
||||||
|
"""Configure the root logger with request-context-aware formatting.
|
||||||
|
|
||||||
|
:param level: The logging level (default ``INFO``).
|
||||||
|
"""
|
||||||
|
root = logging.getLogger()
|
||||||
|
root.setLevel(level)
|
||||||
|
|
||||||
|
# Only add our handler if the root logger has no handlers yet,
|
||||||
|
# or if none of them use our filter.
|
||||||
|
has_context_handler = any(
|
||||||
|
any(isinstance(f, RequestContextFilter) for f in h.filters)
|
||||||
|
for h in root.handlers
|
||||||
|
)
|
||||||
|
if not has_context_handler:
|
||||||
|
handler = logging.StreamHandler()
|
||||||
|
handler.setLevel(level)
|
||||||
|
handler.addFilter(RequestContextFilter())
|
||||||
|
handler.setFormatter(logging.Formatter(DEFAULT_LOG_FORMAT))
|
||||||
|
root.addHandler(handler)
|
||||||
|
|
||||||
|
|
||||||
|
class LoggingMiddleware:
|
||||||
|
"""ASGI middleware that sets per-request context variables.
|
||||||
|
|
||||||
|
For each HTTP request, this middleware:
|
||||||
|
|
||||||
|
1. Extracts or generates a request ID
|
||||||
|
2. Sets context variables for method, path, and client IP
|
||||||
|
3. Logs the request and response with timing information
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, app, logger_name: str = "responder.access"):
|
||||||
|
self.app = app
|
||||||
|
self.logger = get_logger(logger_name)
|
||||||
|
|
||||||
|
async def __call__(self, scope, receive, send):
|
||||||
|
if scope["type"] not in ("http", "websocket"):
|
||||||
|
await self.app(scope, receive, send)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Extract request metadata.
|
||||||
|
headers = dict(scope.get("headers", []))
|
||||||
|
request_id = (
|
||||||
|
headers.get(b"x-request-id", b"").decode() or uuid.uuid4().hex[:8]
|
||||||
|
)
|
||||||
|
method = scope.get("method", "WS")
|
||||||
|
path = scope.get("path", "/")
|
||||||
|
client = scope.get("client")
|
||||||
|
client_ip = client[0] if client else "-"
|
||||||
|
|
||||||
|
# Set context variables for the duration of this request.
|
||||||
|
tok_id = _request_id.set(request_id)
|
||||||
|
tok_method = _request_method.set(method)
|
||||||
|
tok_path = _request_path.set(path)
|
||||||
|
tok_ip = _client_ip.set(client_ip)
|
||||||
|
|
||||||
|
# Track response status.
|
||||||
|
status_code = None
|
||||||
|
|
||||||
|
async def send_wrapper(message):
|
||||||
|
nonlocal status_code
|
||||||
|
if message["type"] == "http.response.start":
|
||||||
|
status_code = message.get("status")
|
||||||
|
# Inject request ID into response headers.
|
||||||
|
headers_list = list(message.get("headers", []))
|
||||||
|
headers_list.append((b"x-request-id", request_id.encode()))
|
||||||
|
message = {**message, "headers": headers_list}
|
||||||
|
await send(message)
|
||||||
|
|
||||||
|
start = time.perf_counter()
|
||||||
|
try:
|
||||||
|
await self.app(scope, receive, send_wrapper)
|
||||||
|
finally:
|
||||||
|
duration_ms = (time.perf_counter() - start) * 1000
|
||||||
|
if scope["type"] == "http":
|
||||||
|
self.logger.info(
|
||||||
|
"%s %s → %s (%.1fms)",
|
||||||
|
method,
|
||||||
|
path,
|
||||||
|
status_code or "?",
|
||||||
|
duration_ms,
|
||||||
|
)
|
||||||
|
# Reset context variables.
|
||||||
|
_request_id.reset(tok_id)
|
||||||
|
_request_method.reset(tok_method)
|
||||||
|
_request_path.reset(tok_path)
|
||||||
|
_client_ip.reset(tok_ip)
|
||||||
@@ -174,8 +174,8 @@ class OpenAPISchema:
|
|||||||
def add_schema(self, name, schema, check_existing=True):
|
def add_schema(self, name, schema, check_existing=True):
|
||||||
"""Adds a marshmallow or Pydantic schema to the API specification."""
|
"""Adds a marshmallow or Pydantic schema to the API specification."""
|
||||||
if check_existing:
|
if check_existing:
|
||||||
assert name not in self.schemas
|
if name in self.schemas or name in self.pydantic_schemas:
|
||||||
assert name not in self.pydantic_schemas
|
raise ValueError(f"Schema '{name}' is already registered")
|
||||||
|
|
||||||
if _is_pydantic_model(schema):
|
if _is_pydantic_model(schema):
|
||||||
self.pydantic_schemas[name] = schema
|
self.pydantic_schemas[name] = schema
|
||||||
@@ -216,12 +216,13 @@ class OpenAPISchema:
|
|||||||
f"{self.docs_theme}.html",
|
f"{self.docs_theme}.html",
|
||||||
title=self.title,
|
title=self.title,
|
||||||
version=self.version,
|
version=self.version,
|
||||||
schema_url="/schema.yml",
|
schema_url=self.openapi_route,
|
||||||
)
|
)
|
||||||
|
|
||||||
def static_url(self, asset):
|
def static_url(self, asset):
|
||||||
"""Given a static asset, return its URL path."""
|
"""Given a static asset, return its URL path."""
|
||||||
assert self.static_route is not None
|
if self.static_route is None:
|
||||||
|
raise RuntimeError("Cannot generate static URL: static_route is disabled")
|
||||||
return f"{self.static_route}/{str(asset)}"
|
return f"{self.static_route}/{str(asset)}"
|
||||||
|
|
||||||
def docs_response(self, req, resp):
|
def docs_response(self, req, resp):
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""Simple in-memory rate limiter for Responder."""
|
"""Simple in-memory rate limiter for Responder."""
|
||||||
|
|
||||||
|
import threading
|
||||||
import time
|
import time
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
|
|
||||||
@@ -28,6 +29,7 @@ class RateLimiter:
|
|||||||
self.max_requests = requests
|
self.max_requests = requests
|
||||||
self.period = period
|
self.period = period
|
||||||
self._buckets: dict[str, list[float]] = defaultdict(list)
|
self._buckets: dict[str, list[float]] = defaultdict(list)
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
def _client_key(self, req):
|
def _client_key(self, req):
|
||||||
client = req.client
|
client = req.client
|
||||||
@@ -39,20 +41,25 @@ class RateLimiter:
|
|||||||
now = time.time()
|
now = time.time()
|
||||||
cutoff = now - self.period
|
cutoff = now - self.period
|
||||||
self._buckets[key] = [t for t in self._buckets[key] if t > cutoff]
|
self._buckets[key] = [t for t in self._buckets[key] if t > cutoff]
|
||||||
|
if not self._buckets[key]:
|
||||||
|
del self._buckets[key]
|
||||||
|
|
||||||
def check(self, req, resp):
|
def check(self, req, resp):
|
||||||
"""Check rate limit. Sets 429 status if exceeded."""
|
"""Check rate limit. Sets 429 status if exceeded."""
|
||||||
key = self._client_key(req)
|
key = self._client_key(req)
|
||||||
self._cleanup(key)
|
|
||||||
|
|
||||||
if len(self._buckets[key]) >= self.max_requests:
|
with self._lock:
|
||||||
resp.status_code = 429
|
self._cleanup(key)
|
||||||
resp.media = {"error": "rate limit exceeded"}
|
|
||||||
resp.headers["Retry-After"] = str(self.period)
|
if len(self._buckets[key]) >= self.max_requests:
|
||||||
return False
|
resp.status_code = 429
|
||||||
|
resp.media = {"error": "rate limit exceeded"}
|
||||||
|
resp.headers["Retry-After"] = str(self.period)
|
||||||
|
return False
|
||||||
|
|
||||||
|
self._buckets[key].append(time.time())
|
||||||
|
remaining = self.max_requests - len(self._buckets[key])
|
||||||
|
|
||||||
self._buckets[key].append(time.time())
|
|
||||||
remaining = self.max_requests - len(self._buckets[key])
|
|
||||||
resp.headers["X-RateLimit-Limit"] = str(self.max_requests)
|
resp.headers["X-RateLimit-Limit"] = str(self.max_requests)
|
||||||
resp.headers["X-RateLimit-Remaining"] = str(remaining)
|
resp.headers["X-RateLimit-Remaining"] = str(remaining)
|
||||||
return True
|
return True
|
||||||
|
|||||||
+15
-4
@@ -34,12 +34,21 @@ class CaseInsensitiveDict(dict):
|
|||||||
def __getitem__(self, key):
|
def __getitem__(self, key):
|
||||||
return super().__getitem__(key.lower())
|
return super().__getitem__(key.lower())
|
||||||
|
|
||||||
|
def __delitem__(self, key):
|
||||||
|
super().__delitem__(key.lower())
|
||||||
|
|
||||||
def __contains__(self, key):
|
def __contains__(self, key):
|
||||||
return super().__contains__(key.lower())
|
return super().__contains__(key.lower())
|
||||||
|
|
||||||
def get(self, key, default=None):
|
def get(self, key, default=None):
|
||||||
return super().get(key.lower(), default)
|
return super().get(key.lower(), default)
|
||||||
|
|
||||||
|
def pop(self, key, *args):
|
||||||
|
return super().pop(key.lower(), *args)
|
||||||
|
|
||||||
|
def setdefault(self, key, default=None):
|
||||||
|
return super().setdefault(key.lower(), default)
|
||||||
|
|
||||||
def update(self, other=None, **kwargs):
|
def update(self, other=None, **kwargs):
|
||||||
if other:
|
if other:
|
||||||
for key, value in other.items():
|
for key, value in other.items():
|
||||||
@@ -299,8 +308,8 @@ class Request:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
if format is None:
|
if format is None:
|
||||||
format = "yaml" if "yaml" in self.mimetype or "" else "json" # noqa: A001
|
format = "yaml" if "yaml" in self.mimetype else "json" # noqa: A001
|
||||||
format = "form" if "form" in self.mimetype or "" else format # noqa: A001
|
format = "form" if "form" in self.mimetype else format # noqa: A001
|
||||||
|
|
||||||
formatter: Callable
|
formatter: Callable
|
||||||
if isinstance(format, str):
|
if isinstance(format, str):
|
||||||
@@ -464,9 +473,11 @@ class Response:
|
|||||||
self.mimetype = guessed or "application/octet-stream"
|
self.mimetype = guessed or "application/octet-stream"
|
||||||
|
|
||||||
async def file_generator():
|
async def file_generator():
|
||||||
with open(path, "rb") as f:
|
import anyio
|
||||||
|
|
||||||
|
async with await anyio.open_file(path, "rb") as f:
|
||||||
while True:
|
while True:
|
||||||
chunk = f.read(chunk_size)
|
chunk = await f.read(chunk_size)
|
||||||
if not chunk:
|
if not chunk:
|
||||||
break
|
break
|
||||||
yield chunk
|
yield chunk
|
||||||
|
|||||||
+21
-10
@@ -130,7 +130,7 @@ class Route(BaseRoute):
|
|||||||
response = Response(req=request, formats=get_formats())
|
response = Response(req=request, formats=get_formats())
|
||||||
|
|
||||||
path_params = scope.get("path_params", {})
|
path_params = scope.get("path_params", {})
|
||||||
before_requests = scope.get("before_requests", [])
|
before_requests = scope.get("before_requests", {"http": [], "ws": []})
|
||||||
|
|
||||||
for before_request in before_requests.get("http", []):
|
for before_request in before_requests.get("http", []):
|
||||||
if inspect.iscoroutinefunction(before_request):
|
if inspect.iscoroutinefunction(before_request):
|
||||||
@@ -144,9 +144,11 @@ class Route(BaseRoute):
|
|||||||
|
|
||||||
# Auto-validate request body with Pydantic model
|
# Auto-validate request body with Pydantic model
|
||||||
req_model = getattr(self.endpoint, "_request_model", None)
|
req_model = getattr(self.endpoint, "_request_model", None)
|
||||||
if req_model is not None and request.method in ("post", "put", "patch"):
|
if req_model is not None and request.method in ("post", "put", "patch", "delete"):
|
||||||
try:
|
try:
|
||||||
body = await request.media()
|
body = await request.media()
|
||||||
|
if not isinstance(body, dict):
|
||||||
|
raise TypeError("Request body must be a JSON object")
|
||||||
req_model(**body)
|
req_model(**body)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
response.status_code = 422
|
response.status_code = 422
|
||||||
@@ -188,7 +190,7 @@ class Route(BaseRoute):
|
|||||||
|
|
||||||
# Auto-serialize response with Pydantic model
|
# Auto-serialize response with Pydantic model
|
||||||
resp_model = getattr(self.endpoint, "_response_model", None)
|
resp_model = getattr(self.endpoint, "_response_model", None)
|
||||||
if resp_model is not None and response.media is not None:
|
if resp_model is not None and isinstance(response.media, dict):
|
||||||
try:
|
try:
|
||||||
validated = resp_model(**response.media)
|
validated = resp_model(**response.media)
|
||||||
response.media = validated.model_dump()
|
response.media = validated.model_dump()
|
||||||
@@ -266,7 +268,7 @@ class WebSocketRoute(BaseRoute):
|
|||||||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||||
ws = WebSocket(scope, receive, send)
|
ws = WebSocket(scope, receive, send)
|
||||||
|
|
||||||
before_requests = scope.get("before_requests", [])
|
before_requests = scope.get("before_requests", {"http": [], "ws": []})
|
||||||
for before_request in before_requests.get("ws", []):
|
for before_request in before_requests.get("ws", []):
|
||||||
await before_request(ws)
|
await before_request(ws)
|
||||||
|
|
||||||
@@ -459,13 +461,22 @@ class Router:
|
|||||||
if path.startswith(path_prefix):
|
if path.startswith(path_prefix):
|
||||||
scope["path"] = path[len(path_prefix) :] or "/"
|
scope["path"] = path[len(path_prefix) :] or "/"
|
||||||
scope["root_path"] = root_path + path_prefix
|
scope["root_path"] = root_path + path_prefix
|
||||||
try:
|
|
||||||
await app(scope, receive, send)
|
|
||||||
return
|
|
||||||
except TypeError:
|
|
||||||
from a2wsgi import WSGIMiddleware
|
|
||||||
|
|
||||||
app = WSGIMiddleware(app)
|
if not (inspect.iscoroutinefunction(app) or hasattr(app, "__asgi_app__")):
|
||||||
|
# Check if it looks like a WSGI app (callable with fewer params)
|
||||||
|
try:
|
||||||
|
await app(scope, receive, send)
|
||||||
|
return
|
||||||
|
except TypeError as exc:
|
||||||
|
# Only fall back to WSGI if the error is about call signature
|
||||||
|
if "argument" not in str(exc) and "positional" not in str(exc):
|
||||||
|
raise
|
||||||
|
from a2wsgi import WSGIMiddleware
|
||||||
|
|
||||||
|
app = WSGIMiddleware(app)
|
||||||
|
await app(scope, receive, send)
|
||||||
|
return
|
||||||
|
else:
|
||||||
await app(scope, receive, send)
|
await app(scope, receive, send)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ for number in codes:
|
|||||||
|
|
||||||
|
|
||||||
def _is_category(category, status_code):
|
def _is_category(category, status_code):
|
||||||
return all([(status_code >= category), (status_code < category + 100)])
|
return category <= status_code < category + 100
|
||||||
|
|
||||||
|
|
||||||
def is_100(status_code):
|
def is_100(status_code):
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ __all__ = [
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def load_target(target: str, default_property: str = "api", method: str = "run") -> t.Any:
|
def load_target(target: str, default_property: str = "api") -> t.Any:
|
||||||
"""
|
"""
|
||||||
Load Python code from a file path or module name.
|
Load Python code from a file path or module name.
|
||||||
|
|
||||||
@@ -24,7 +24,6 @@ def load_target(target: str, default_property: str = "api", method: str = "run")
|
|||||||
target: Module address (e.g., 'acme.app:foo'), file path (e.g., '/path/to/acme/app.py'),
|
target: Module address (e.g., 'acme.app:foo'), file path (e.g., '/path/to/acme/app.py'),
|
||||||
or URL.
|
or URL.
|
||||||
default_property: Name of the property to load if not specified in target (default: "api")
|
default_property: Name of the property to load if not specified in target (default: "api")
|
||||||
method: Name of the method to invoke on the API instance (default: "run")
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The API instance, loaded from the given property.
|
The API instance, loaded from the given property.
|
||||||
@@ -32,7 +31,7 @@ def load_target(target: str, default_property: str = "api", method: str = "run")
|
|||||||
Raises:
|
Raises:
|
||||||
ValueError: If target format is invalid
|
ValueError: If target format is invalid
|
||||||
ImportError: If module cannot be imported
|
ImportError: If module cannot be imported
|
||||||
AttributeError: If property or method is not found
|
AttributeError: If property is not found
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
>>> api = load_target("myapp.api:server")
|
>>> api = load_target("myapp.api:server")
|
||||||
|
|||||||
+6
-1
@@ -60,7 +60,12 @@ def test_cli_version(capfd):
|
|||||||
)
|
)
|
||||||
|
|
||||||
stdout = capfd.readouterr().out.strip()
|
stdout = capfd.readouterr().out.strip()
|
||||||
assert stdout == __version__
|
|
||||||
|
# TODO: Accommodate PyPy as installed by `uv`, it emits spurious output on stdout before the version number.
|
||||||
|
# AssertionError: assert '(_common_types_metatype, 9088, 128, 128)\n(cython_function_or_method, 157568, 128, 128)\n3.6.0' == '3.6.0'
|
||||||
|
lines = [line.strip() for line in stdout.splitlines() if line.strip()]
|
||||||
|
assert lines, "Expected version output on stdout"
|
||||||
|
assert lines[-1] == __version__
|
||||||
|
|
||||||
|
|
||||||
def responder_build(path: Path, capfd: CaptureFixture) -> t.Tuple[str, str]:
|
def responder_build(path: Path, capfd: CaptureFixture) -> t.Tuple[str, str]:
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ def test_background_task_exception(capsys):
|
|||||||
time.sleep(0.2) # let the done callback fire
|
time.sleep(0.2) # let the done callback fire
|
||||||
|
|
||||||
captured = capsys.readouterr()
|
captured = capsys.readouterr()
|
||||||
assert "ValueError" in captured.err or True # traceback goes to stderr
|
assert "ValueError" in captured.err or "ValueError" in captured.out
|
||||||
|
|
||||||
|
|
||||||
def test_background_run():
|
def test_background_run():
|
||||||
@@ -112,7 +112,8 @@ def test_form_uploads_without_multipart(api):
|
|||||||
content="name=hello&value=world",
|
content="name=hello&value=world",
|
||||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||||
)
|
)
|
||||||
assert r.json() == {"name": "world", "value": "world"} or r.status_code < 500
|
assert r.status_code == 200
|
||||||
|
assert r.json() == {"name": ["hello"], "value": ["world"]}
|
||||||
|
|
||||||
|
|
||||||
# --- models.py coverage ---
|
# --- models.py coverage ---
|
||||||
@@ -469,7 +470,7 @@ def test_graphql_text_query(api):
|
|||||||
content="{ hello }",
|
content="{ hello }",
|
||||||
headers={"Content-Type": "text/plain"},
|
headers={"Content-Type": "text/plain"},
|
||||||
)
|
)
|
||||||
assert r.status_code < 500
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
def test_openapi_info_fields():
|
def test_openapi_info_fields():
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
"""Tests for structured logging with request context."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import responder
|
||||||
|
from responder.ext.logging import (
|
||||||
|
LoggingMiddleware,
|
||||||
|
RequestContext,
|
||||||
|
RequestContextFilter,
|
||||||
|
get_logger,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_logging_middleware_sets_request_id():
|
||||||
|
"""LoggingMiddleware adds X-Request-ID to responses."""
|
||||||
|
api = responder.API(allowed_hosts=["localhost"], enable_logging=True)
|
||||||
|
|
||||||
|
@api.route("/")
|
||||||
|
def index(req, resp):
|
||||||
|
resp.text = "ok"
|
||||||
|
|
||||||
|
r = api.requests.get("http://localhost/")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert "x-request-id" in r.headers
|
||||||
|
assert len(r.headers["x-request-id"]) > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_logging_middleware_forwards_request_id():
|
||||||
|
"""LoggingMiddleware forwards client-provided X-Request-ID."""
|
||||||
|
api = responder.API(allowed_hosts=["localhost"], enable_logging=True)
|
||||||
|
|
||||||
|
@api.route("/")
|
||||||
|
def index(req, resp):
|
||||||
|
resp.text = "ok"
|
||||||
|
|
||||||
|
r = api.requests.get(
|
||||||
|
"http://localhost/", headers={"X-Request-ID": "custom-id-123"}
|
||||||
|
)
|
||||||
|
assert r.headers["x-request-id"] == "custom-id-123"
|
||||||
|
|
||||||
|
|
||||||
|
def test_logging_context_available_in_route():
|
||||||
|
"""Request context is available inside route handlers."""
|
||||||
|
api = responder.API(allowed_hosts=["localhost"], enable_logging=True)
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
@api.route("/ctx")
|
||||||
|
def index(req, resp):
|
||||||
|
captured["request_id"] = RequestContext.get_request_id()
|
||||||
|
captured["method"] = RequestContext.get_method()
|
||||||
|
captured["path"] = RequestContext.get_path()
|
||||||
|
captured["client_ip"] = RequestContext.get_client_ip()
|
||||||
|
resp.text = "ok"
|
||||||
|
|
||||||
|
api.requests.get("http://localhost/ctx")
|
||||||
|
assert captured["method"] == "GET"
|
||||||
|
assert captured["path"] == "/ctx"
|
||||||
|
assert captured["request_id"] != "-"
|
||||||
|
assert captured["client_ip"] != "-"
|
||||||
|
|
||||||
|
|
||||||
|
def test_logging_filter_injects_attributes():
|
||||||
|
"""RequestContextFilter adds context fields to log records."""
|
||||||
|
logger = get_logger("test.filter")
|
||||||
|
records = []
|
||||||
|
|
||||||
|
class CaptureHandler(logging.Handler):
|
||||||
|
def emit(self, record):
|
||||||
|
records.append(record)
|
||||||
|
|
||||||
|
handler = CaptureHandler()
|
||||||
|
logger.addHandler(handler)
|
||||||
|
logger.setLevel(logging.DEBUG)
|
||||||
|
|
||||||
|
api = responder.API(allowed_hosts=["localhost"], enable_logging=True)
|
||||||
|
|
||||||
|
@api.route("/log")
|
||||||
|
def index(req, resp):
|
||||||
|
logger.info("test message")
|
||||||
|
resp.text = "ok"
|
||||||
|
|
||||||
|
api.requests.get("http://localhost/log")
|
||||||
|
|
||||||
|
logger.removeHandler(handler)
|
||||||
|
|
||||||
|
assert len(records) > 0
|
||||||
|
record = records[0]
|
||||||
|
assert hasattr(record, "request_id")
|
||||||
|
assert hasattr(record, "request_method")
|
||||||
|
assert hasattr(record, "request_path")
|
||||||
|
assert hasattr(record, "client_ip")
|
||||||
|
assert record.request_method == "GET"
|
||||||
|
assert record.request_path == "/log"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_logger_avoids_duplicate_filters():
|
||||||
|
"""get_logger doesn't add duplicate filters."""
|
||||||
|
logger = get_logger("test.dedup")
|
||||||
|
count_before = sum(1 for f in logger.filters if isinstance(f, RequestContextFilter))
|
||||||
|
get_logger("test.dedup")
|
||||||
|
count_after = sum(1 for f in logger.filters if isinstance(f, RequestContextFilter))
|
||||||
|
assert count_before == count_after == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_enable_logging_supersedes_request_id():
|
||||||
|
"""enable_logging handles request IDs itself (no duplicate headers)."""
|
||||||
|
api = responder.API(
|
||||||
|
allowed_hosts=["localhost"], request_id=True, enable_logging=True
|
||||||
|
)
|
||||||
|
|
||||||
|
@api.route("/")
|
||||||
|
def index(req, resp):
|
||||||
|
resp.text = "ok"
|
||||||
|
|
||||||
|
r = api.requests.get("http://localhost/")
|
||||||
|
# Should have exactly one X-Request-ID header.
|
||||||
|
assert "x-request-id" in r.headers
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_logger_attribute():
|
||||||
|
"""api.log is available when enable_logging=True."""
|
||||||
|
api = responder.API(allowed_hosts=["localhost"], enable_logging=True)
|
||||||
|
assert api.log is not None
|
||||||
|
assert api.log.name == "responder.app"
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_log_always_available():
|
||||||
|
"""api.log works even without enable_logging — just no request context."""
|
||||||
|
api = responder.API(allowed_hosts=["localhost"])
|
||||||
|
assert api.log is not None
|
||||||
|
assert api.log.name == "responder.app"
|
||||||
|
api.log.info("works without enable_logging")
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_logger_works_in_routes():
|
||||||
|
"""api.log can be used inside route handlers with context."""
|
||||||
|
api = responder.API(allowed_hosts=["localhost"], enable_logging=True)
|
||||||
|
records = []
|
||||||
|
|
||||||
|
class CaptureHandler(logging.Handler):
|
||||||
|
def emit(self, record):
|
||||||
|
records.append(record)
|
||||||
|
|
||||||
|
handler = CaptureHandler()
|
||||||
|
api.log.addHandler(handler)
|
||||||
|
|
||||||
|
@api.route("/")
|
||||||
|
def index(req, resp):
|
||||||
|
api.log.info("hello from route")
|
||||||
|
resp.text = "ok"
|
||||||
|
|
||||||
|
api.requests.get("http://localhost/")
|
||||||
|
api.log.removeHandler(handler)
|
||||||
|
|
||||||
|
assert any(r.msg == "hello from route" for r in records)
|
||||||
|
record = next(r for r in records if r.msg == "hello from route")
|
||||||
|
assert record.request_method == "GET"
|
||||||
|
assert record.request_path == "/"
|
||||||
Reference in New Issue
Block a user