mirror of
https://github.com/d3vyce/fastapi-toolsets.git
synced 2026-08-13 19:42:59 +00:00
Compare commits
1
Commits
v2.1.0
..
a66eb50149
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a66eb50149
|
@@ -87,37 +87,6 @@ await wait_for_row_change(
|
|||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Creating a database
|
|
||||||
|
|
||||||
!!! info "Added in `v2.1`"
|
|
||||||
|
|
||||||
[`create_database`](../reference/db.md#fastapi_toolsets.db.create_database) creates a database at a given URL. It connects to *server_url* and issues a `CREATE DATABASE` statement:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from fastapi_toolsets.db import create_database
|
|
||||||
|
|
||||||
SERVER_URL = "postgresql+asyncpg://postgres:postgres@localhost/postgres"
|
|
||||||
|
|
||||||
await create_database(db_name="myapp_test", server_url=SERVER_URL)
|
|
||||||
```
|
|
||||||
|
|
||||||
For test isolation with automatic cleanup, use [`create_worker_database`](../reference/pytest.md#fastapi_toolsets.pytest.utils.create_worker_database) from the `pytest` module instead — it handles drop-before, create, and drop-after automatically.
|
|
||||||
|
|
||||||
## Cleaning up tables
|
|
||||||
|
|
||||||
!!! info "Added in `v2.1`"
|
|
||||||
|
|
||||||
[`cleanup_tables`](../reference/db.md#fastapi_toolsets.db.cleanup_tables) truncates all tables:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from fastapi_toolsets.db import cleanup_tables
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
|
||||||
async def clean(db_session):
|
|
||||||
yield
|
|
||||||
await cleanup_tables(session=db_session, base=Base)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
[:material-api: API Reference](../reference/db.md)
|
[:material-api: API Reference](../reference/db.md)
|
||||||
|
|||||||
+5
-28
@@ -36,13 +36,7 @@ This mounts the `/metrics` endpoint that Prometheus can scrape.
|
|||||||
|
|
||||||
### Providers
|
### Providers
|
||||||
|
|
||||||
Providers are called once at startup by `init_metrics`. The return value (the Prometheus metric object) is stored in the registry and can be retrieved later with [`registry.get(name)`](../reference/metrics.md#fastapi_toolsets.metrics.registry.MetricsRegistry.get).
|
Providers are called once at startup and register metrics that are updated externally (e.g. counters, histograms):
|
||||||
|
|
||||||
Use providers when you want **deferred initialization**: the Prometheus metric is not registered with the global `CollectorRegistry` until `init_metrics` runs, not at import time. This is particularly useful for testing — importing the module in a test suite without calling `init_metrics` leaves no metrics registered, avoiding cross-test pollution.
|
|
||||||
|
|
||||||
It is also useful when metrics are defined across multiple modules and merged with `include_registry`: any code that needs a metric can call `metrics.get()` on the shared registry instead of importing the metric directly from its origin module.
|
|
||||||
|
|
||||||
If neither of these applies to you, declaring metrics at module level (e.g. `HTTP_REQUESTS = Counter(...)`) is simpler and equally valid.
|
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from prometheus_client import Counter, Histogram
|
from prometheus_client import Counter, Histogram
|
||||||
@@ -56,32 +50,15 @@ def request_duration():
|
|||||||
return Histogram("request_duration_seconds", "Request duration")
|
return Histogram("request_duration_seconds", "Request duration")
|
||||||
```
|
```
|
||||||
|
|
||||||
To use a provider's metric elsewhere (e.g. in a middleware), call `metrics.get()` inside the handler — **not** at module level, as providers are only initialized when `init_metrics` runs:
|
|
||||||
|
|
||||||
```python
|
|
||||||
async def metrics_middleware(request: Request, call_next):
|
|
||||||
response = await call_next(request)
|
|
||||||
metrics.get("http_requests").labels(
|
|
||||||
method=request.method, status=response.status_code
|
|
||||||
).inc()
|
|
||||||
return response
|
|
||||||
```
|
|
||||||
|
|
||||||
### Collectors
|
### Collectors
|
||||||
|
|
||||||
Collectors are called on every scrape. Use them for metrics that reflect current state (e.g. gauges).
|
Collectors are called on every scrape. Use them for metrics that reflect current state (e.g. gauges):
|
||||||
|
|
||||||
!!! warning "Declare the metric at module level"
|
|
||||||
Do **not** instantiate the Prometheus metric inside the collector function. Doing so recreates it on every scrape, raising `ValueError: Duplicated timeseries in CollectorRegistry`. Declare it once at module level instead:
|
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from prometheus_client import Gauge
|
|
||||||
|
|
||||||
_queue_depth = Gauge("queue_depth", "Current queue depth")
|
|
||||||
|
|
||||||
@metrics.register(collect=True)
|
@metrics.register(collect=True)
|
||||||
def collect_queue_depth():
|
def queue_depth():
|
||||||
_queue_depth.set(get_current_queue_depth())
|
gauge = Gauge("queue_depth", "Current queue depth")
|
||||||
|
gauge.set(get_current_queue_depth())
|
||||||
```
|
```
|
||||||
|
|
||||||
## Merging registries
|
## Merging registries
|
||||||
|
|||||||
+4
-16
@@ -40,10 +40,10 @@ async def http_client(db_session):
|
|||||||
|
|
||||||
## Database sessions in tests
|
## Database sessions in tests
|
||||||
|
|
||||||
Use [`create_db_session`](../reference/pytest.md#fastapi_toolsets.pytest.utils.create_db_session) to create an isolated `AsyncSession` for a test, combined with [`create_worker_database`](../reference/pytest.md#fastapi_toolsets.pytest.utils.create_worker_database) to set up a per-worker database:
|
Use [`create_db_session`](../reference/pytest.md#fastapi_toolsets.pytest.utils.create_db_session) to create an isolated `AsyncSession` for a test:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from fastapi_toolsets.pytest import create_worker_database, create_db_session
|
from fastapi_toolsets.pytest import create_db_session, create_worker_database
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
@pytest.fixture(scope="session")
|
||||||
async def worker_db_url():
|
async def worker_db_url():
|
||||||
@@ -64,28 +64,16 @@ async def db_session(worker_db_url):
|
|||||||
!!! info
|
!!! info
|
||||||
In this example, the database is reset between each test using the argument `cleanup=True`.
|
In this example, the database is reset between each test using the argument `cleanup=True`.
|
||||||
|
|
||||||
Use [`worker_database_url`](../reference/pytest.md#fastapi_toolsets.pytest.utils.worker_database_url) to derive the per-worker URL manually if needed:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from fastapi_toolsets.pytest import worker_database_url
|
|
||||||
|
|
||||||
url = worker_database_url("postgresql+asyncpg://user:pass@localhost/test_db", default_test_db="test")
|
|
||||||
# e.g. "postgresql+asyncpg://user:pass@localhost/test_db_gw0" under xdist
|
|
||||||
```
|
|
||||||
|
|
||||||
## Parallel testing with pytest-xdist
|
## Parallel testing with pytest-xdist
|
||||||
|
|
||||||
The examples above are already compatible with parallel test execution with `pytest-xdist`.
|
The examples above are already compatible with parallel test execution with `pytest-xdist`.
|
||||||
|
|
||||||
## Cleaning up tables
|
## Cleaning up tables
|
||||||
|
|
||||||
!!! warning
|
If you want to manually clean up a database you can use [`cleanup_tables`](../reference/pytest.md#fastapi_toolsets.pytest.utils.cleanup_tables), this will truncates all tables between tests for fast isolation:
|
||||||
Since `V2.1.0` `cleanup_tables` now live in `fastapi_toolsets.db`. For backward compatibility the function is still available in `fastapi_toolsets.pytest`, but this will be remove in `V3.0.0`.
|
|
||||||
|
|
||||||
If you want to manually clean up a database you can use [`cleanup_tables`](../reference/db.md#fastapi_toolsets.db.cleanup_tables), this will truncate all tables between tests for fast isolation:
|
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from fastapi_toolsets.db import cleanup_tables
|
from fastapi_toolsets.pytest import cleanup_tables
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
async def clean(db_session):
|
async def clean(db_session):
|
||||||
|
|||||||
@@ -0,0 +1,267 @@
|
|||||||
|
# Security
|
||||||
|
|
||||||
|
Composable authentication helpers for FastAPI that use `Security()` for OpenAPI documentation and accept user-provided validator functions with full type flexibility.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The `security` module provides four auth source classes and a `MultiAuth` factory. Each class wraps a FastAPI security scheme for OpenAPI and accepts a validator function called as:
|
||||||
|
|
||||||
|
```python
|
||||||
|
await validator(credential, **kwargs)
|
||||||
|
```
|
||||||
|
|
||||||
|
where `kwargs` are the extra keyword arguments provided at instantiation (roles, permissions, enums, etc.). The validator returns the authenticated identity (e.g. a `User` model) which becomes the route dependency value.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi import Security
|
||||||
|
from fastapi_toolsets.security import BearerTokenAuth
|
||||||
|
|
||||||
|
async def verify_token(token: str, *, role: str) -> User:
|
||||||
|
user = await db.get_by_token(token)
|
||||||
|
if not user or user.role != role:
|
||||||
|
raise UnauthorizedError()
|
||||||
|
return user
|
||||||
|
|
||||||
|
bearer_admin = BearerTokenAuth(verify_token, role="admin")
|
||||||
|
|
||||||
|
@app.get("/admin")
|
||||||
|
async def admin_route(user: User = Security(bearer_admin)):
|
||||||
|
return user
|
||||||
|
```
|
||||||
|
|
||||||
|
## Auth sources
|
||||||
|
|
||||||
|
### [`BearerTokenAuth`](../reference/security.md#fastapi_toolsets.security.BearerTokenAuth)
|
||||||
|
|
||||||
|
Reads the `Authorization: Bearer <token>` header. Wraps `HTTPBearer` for OpenAPI.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi_toolsets.security import BearerTokenAuth
|
||||||
|
|
||||||
|
bearer = BearerTokenAuth(validator=verify_token)
|
||||||
|
|
||||||
|
@app.get("/me")
|
||||||
|
async def me(user: User = Security(bearer)):
|
||||||
|
return user
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Token prefix
|
||||||
|
|
||||||
|
The optional `prefix` parameter restricts a `BearerTokenAuth` instance to tokens
|
||||||
|
that start with a given string. The prefix is **kept** in the value passed to the
|
||||||
|
validator — store and compare tokens with their prefix included.
|
||||||
|
|
||||||
|
This lets you deploy multiple `BearerTokenAuth` instances in the same application
|
||||||
|
and disambiguate them efficiently in `MultiAuth`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
user_bearer = BearerTokenAuth(verify_user, prefix="user_") # matches "Bearer user_..."
|
||||||
|
org_bearer = BearerTokenAuth(verify_org, prefix="org_") # matches "Bearer org_..."
|
||||||
|
```
|
||||||
|
|
||||||
|
Use [`generate_token()`](#token-generation) to create correctly-prefixed tokens.
|
||||||
|
|
||||||
|
#### Token generation
|
||||||
|
|
||||||
|
`BearerTokenAuth.generate_token()` produces a secure random token ready to store
|
||||||
|
in your database and return to the client. If a prefix is configured it is
|
||||||
|
prepended automatically:
|
||||||
|
|
||||||
|
```python
|
||||||
|
bearer = BearerTokenAuth(verify_token, prefix="user_")
|
||||||
|
|
||||||
|
token = bearer.generate_token() # e.g. "user_Xk3mN..."
|
||||||
|
await db.store_token(user_id, token)
|
||||||
|
return {"access_token": token, "token_type": "bearer"}
|
||||||
|
```
|
||||||
|
|
||||||
|
The client sends `Authorization: Bearer user_Xk3mN...` and the validator receives
|
||||||
|
the full token (prefix included) to compare against the stored value.
|
||||||
|
|
||||||
|
### [`CookieAuth`](../reference/security.md#fastapi_toolsets.security.CookieAuth)
|
||||||
|
|
||||||
|
Reads a named cookie. Wraps `APIKeyCookie` for OpenAPI.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi_toolsets.security import CookieAuth
|
||||||
|
|
||||||
|
cookie_auth = CookieAuth("session", validator=verify_session)
|
||||||
|
|
||||||
|
@app.get("/me")
|
||||||
|
async def me(user: User = Security(cookie_auth)):
|
||||||
|
return user
|
||||||
|
```
|
||||||
|
|
||||||
|
### [`OAuth2Auth`](../reference/security.md#fastapi_toolsets.security.OAuth2Auth)
|
||||||
|
|
||||||
|
Reads the `Authorization: Bearer <token>` header and registers the token endpoint
|
||||||
|
in OpenAPI via `OAuth2PasswordBearer`.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi_toolsets.security import OAuth2Auth
|
||||||
|
|
||||||
|
oauth2_auth = OAuth2Auth(token_url="/token", validator=verify_token)
|
||||||
|
|
||||||
|
@app.get("/me")
|
||||||
|
async def me(user: User = Security(oauth2_auth)):
|
||||||
|
return user
|
||||||
|
```
|
||||||
|
|
||||||
|
### [`OpenIDAuth`](../reference/security.md#fastapi_toolsets.security.OpenIDAuth)
|
||||||
|
|
||||||
|
Reads the `Authorization: Bearer <token>` header and registers the OpenID Connect
|
||||||
|
discovery URL in OpenAPI via `OpenIdConnect`. Token validation is fully delegated
|
||||||
|
to your validator — use any OIDC / JWT library (`authlib`, `python-jose`, `PyJWT`).
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi_toolsets.security import OpenIDAuth
|
||||||
|
|
||||||
|
async def verify_google_token(token: str, *, audience: str) -> User:
|
||||||
|
payload = jwt.decode(token, google_public_keys, algorithms=["RS256"],
|
||||||
|
audience=audience)
|
||||||
|
return User(email=payload["email"], name=payload["name"])
|
||||||
|
|
||||||
|
google_auth = OpenIDAuth(
|
||||||
|
"https://accounts.google.com/.well-known/openid-configuration",
|
||||||
|
verify_google_token,
|
||||||
|
audience="my-client-id",
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.get("/me")
|
||||||
|
async def me(user: User = Security(google_auth)):
|
||||||
|
return user
|
||||||
|
```
|
||||||
|
|
||||||
|
The discovery URL is used **only for OpenAPI documentation** — no requests are made
|
||||||
|
to it by this class. You are responsible for fetching and caching the provider's
|
||||||
|
public keys in your validator.
|
||||||
|
|
||||||
|
Multiple providers work naturally with `MultiAuth`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
multi = MultiAuth(google_auth, github_auth)
|
||||||
|
|
||||||
|
@app.get("/data")
|
||||||
|
async def data(user: User = Security(multi)):
|
||||||
|
return user
|
||||||
|
```
|
||||||
|
|
||||||
|
## Typed validator kwargs
|
||||||
|
|
||||||
|
All auth classes forward extra instantiation keyword arguments to the validator.
|
||||||
|
Arguments can be any type — enums, strings, integers, etc. The validator returns
|
||||||
|
the authenticated identity, which FastAPI injects directly into the route handler.
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def verify_token(token: str, *, role: Role, permission: str) -> User:
|
||||||
|
user = await decode_token(token)
|
||||||
|
if user.role != role or permission not in user.permissions:
|
||||||
|
raise UnauthorizedError()
|
||||||
|
return user
|
||||||
|
|
||||||
|
bearer = BearerTokenAuth(verify_token, role=Role.ADMIN, permission="billing:read")
|
||||||
|
```
|
||||||
|
|
||||||
|
Each auth instance is self-contained — create a separate instance per distinct
|
||||||
|
requirement instead of passing requirements through `Security(scopes=[...])`.
|
||||||
|
|
||||||
|
### Using `.require()` inline
|
||||||
|
|
||||||
|
If declaring a new top-level variable per role feels verbose, use `.require()` to
|
||||||
|
create a configured clone directly in the route decorator. The original instance
|
||||||
|
is not mutated:
|
||||||
|
|
||||||
|
```python
|
||||||
|
bearer = BearerTokenAuth(verify_token)
|
||||||
|
|
||||||
|
@app.get("/admin/stats")
|
||||||
|
async def admin_stats(user: User = Security(bearer.require(role=Role.ADMIN))):
|
||||||
|
return {"message": f"Hello admin {user.name}"}
|
||||||
|
|
||||||
|
@app.get("/profile")
|
||||||
|
async def profile(user: User = Security(bearer.require(role=Role.USER))):
|
||||||
|
return {"id": user.id, "name": user.name}
|
||||||
|
```
|
||||||
|
|
||||||
|
`.require()` kwargs are merged over existing ones — new values win on conflict.
|
||||||
|
The `prefix` (for `BearerTokenAuth`) and cookie name (for `CookieAuth`) are
|
||||||
|
always preserved.
|
||||||
|
|
||||||
|
`.require()` instances work transparently inside `MultiAuth`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
multi = MultiAuth(
|
||||||
|
user_bearer.require(role=Role.USER),
|
||||||
|
org_bearer.require(role=Role.ADMIN),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## MultiAuth
|
||||||
|
|
||||||
|
[`MultiAuth`](../reference/security.md#fastapi_toolsets.security.MultiAuth) combines
|
||||||
|
multiple auth sources into a single callable. Sources are tried in order; the
|
||||||
|
first one that finds a credential wins.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi_toolsets.security import MultiAuth
|
||||||
|
|
||||||
|
multi = MultiAuth(user_bearer, org_bearer, cookie_auth)
|
||||||
|
|
||||||
|
@app.get("/data")
|
||||||
|
async def data_route(user = Security(multi)):
|
||||||
|
return user
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using `.require()` on MultiAuth
|
||||||
|
|
||||||
|
`MultiAuth` also supports `.require()`, which propagates the kwargs to every
|
||||||
|
source that implements it. Sources that do not (e.g. custom `AuthSource`
|
||||||
|
subclasses) are passed through unchanged:
|
||||||
|
|
||||||
|
```python
|
||||||
|
multi = MultiAuth(bearer, cookie)
|
||||||
|
|
||||||
|
@app.get("/admin")
|
||||||
|
async def admin(user: User = Security(multi.require(role=Role.ADMIN))):
|
||||||
|
return user
|
||||||
|
```
|
||||||
|
|
||||||
|
This is equivalent to calling `.require()` on each source individually:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# These two are identical
|
||||||
|
multi.require(role=Role.ADMIN)
|
||||||
|
|
||||||
|
MultiAuth(
|
||||||
|
bearer.require(role=Role.ADMIN),
|
||||||
|
cookie.require(role=Role.ADMIN),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Prefix-based dispatch
|
||||||
|
|
||||||
|
Because `extract()` is pure string matching (no I/O), prefix-based source
|
||||||
|
selection is essentially free. Only the matching source's validator (which may
|
||||||
|
involve DB or network I/O) is ever called:
|
||||||
|
|
||||||
|
```python
|
||||||
|
user_bearer = BearerTokenAuth(verify_user, prefix="user_")
|
||||||
|
org_bearer = BearerTokenAuth(verify_org, prefix="org_")
|
||||||
|
|
||||||
|
multi = MultiAuth(user_bearer, org_bearer)
|
||||||
|
|
||||||
|
# "Bearer user_alice" → only verify_user runs, receives "user_alice"
|
||||||
|
# "Bearer org_acme" → only verify_org runs, receives "org_acme"
|
||||||
|
```
|
||||||
|
|
||||||
|
Tokens are stored and compared **with their prefix** — use `generate_token()` on
|
||||||
|
each source to issue correctly-prefixed tokens:
|
||||||
|
|
||||||
|
```python
|
||||||
|
user_token = user_bearer.generate_token() # "user_..."
|
||||||
|
org_token = org_bearer.generate_token() # "org_..."
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[:material-api: API Reference](../reference/security.md)
|
||||||
@@ -7,8 +7,6 @@ You can import them directly from `fastapi_toolsets.db`:
|
|||||||
```python
|
```python
|
||||||
from fastapi_toolsets.db import (
|
from fastapi_toolsets.db import (
|
||||||
LockMode,
|
LockMode,
|
||||||
cleanup_tables,
|
|
||||||
create_database,
|
|
||||||
create_db_dependency,
|
create_db_dependency,
|
||||||
create_db_context,
|
create_db_context,
|
||||||
get_transaction,
|
get_transaction,
|
||||||
@@ -28,7 +26,3 @@ from fastapi_toolsets.db import (
|
|||||||
## ::: fastapi_toolsets.db.lock_tables
|
## ::: fastapi_toolsets.db.lock_tables
|
||||||
|
|
||||||
## ::: fastapi_toolsets.db.wait_for_row_change
|
## ::: fastapi_toolsets.db.wait_for_row_change
|
||||||
|
|
||||||
## ::: fastapi_toolsets.db.create_database
|
|
||||||
|
|
||||||
## ::: fastapi_toolsets.db.cleanup_tables
|
|
||||||
|
|||||||
@@ -24,3 +24,5 @@ from fastapi_toolsets.pytest import (
|
|||||||
## ::: fastapi_toolsets.pytest.utils.worker_database_url
|
## ::: fastapi_toolsets.pytest.utils.worker_database_url
|
||||||
|
|
||||||
## ::: fastapi_toolsets.pytest.utils.create_worker_database
|
## ::: fastapi_toolsets.pytest.utils.create_worker_database
|
||||||
|
|
||||||
|
## ::: fastapi_toolsets.pytest.utils.cleanup_tables
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# `security`
|
||||||
|
|
||||||
|
Here's the reference for the authentication helpers provided by the `security` module.
|
||||||
|
|
||||||
|
You can import them directly from `fastapi_toolsets.security`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi_toolsets.security import (
|
||||||
|
AuthSource,
|
||||||
|
BearerTokenAuth,
|
||||||
|
CookieAuth,
|
||||||
|
OAuth2Auth,
|
||||||
|
OpenIDAuth,
|
||||||
|
MultiAuth,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## ::: fastapi_toolsets.security.AuthSource
|
||||||
|
|
||||||
|
## ::: fastapi_toolsets.security.BearerTokenAuth
|
||||||
|
|
||||||
|
## ::: fastapi_toolsets.security.CookieAuth
|
||||||
|
|
||||||
|
## ::: fastapi_toolsets.security.OAuth2Auth
|
||||||
|
|
||||||
|
## ::: fastapi_toolsets.security.OpenIDAuth
|
||||||
|
|
||||||
|
## ::: fastapi_toolsets.security.MultiAuth
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "fastapi-toolsets"
|
name = "fastapi-toolsets"
|
||||||
version = "2.1.0"
|
version = "2.0.0"
|
||||||
description = "Production-ready utilities for FastAPI applications"
|
description = "Production-ready utilities for FastAPI applications"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|||||||
@@ -21,4 +21,4 @@ Example usage:
|
|||||||
return Response(data={"user": user.username}, message="Success")
|
return Response(data={"user": user.username}, message="Success")
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__version__ = "2.1.0"
|
__version__ = "2.0.0"
|
||||||
|
|||||||
@@ -7,19 +7,17 @@ from enum import Enum
|
|||||||
from typing import Any, TypeVar
|
from typing import Any, TypeVar
|
||||||
|
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||||
from sqlalchemy.orm import DeclarativeBase
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
|
||||||
from .exceptions import NotFoundError
|
from .exceptions import NotFoundError
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"LockMode",
|
"LockMode",
|
||||||
"cleanup_tables",
|
|
||||||
"create_database",
|
|
||||||
"create_db_context",
|
"create_db_context",
|
||||||
"create_db_dependency",
|
"create_db_dependency",
|
||||||
"get_transaction",
|
|
||||||
"lock_tables",
|
"lock_tables",
|
||||||
|
"get_transaction",
|
||||||
"wait_for_row_change",
|
"wait_for_row_change",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -190,71 +188,6 @@ async def lock_tables(
|
|||||||
yield session
|
yield session
|
||||||
|
|
||||||
|
|
||||||
async def create_database(
|
|
||||||
db_name: str,
|
|
||||||
*,
|
|
||||||
server_url: str,
|
|
||||||
) -> None:
|
|
||||||
"""Create a database.
|
|
||||||
|
|
||||||
Connects to *server_url* using ``AUTOCOMMIT`` isolation and issues a
|
|
||||||
``CREATE DATABASE`` statement for *db_name*.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
db_name: Name of the database to create.
|
|
||||||
server_url: URL used for server-level DDL (must point to an existing
|
|
||||||
database on the same server).
|
|
||||||
|
|
||||||
Example:
|
|
||||||
```python
|
|
||||||
from fastapi_toolsets.db import create_database
|
|
||||||
|
|
||||||
SERVER_URL = "postgresql+asyncpg://postgres:postgres@localhost/postgres"
|
|
||||||
await create_database("myapp_test", server_url=SERVER_URL)
|
|
||||||
```
|
|
||||||
"""
|
|
||||||
engine = create_async_engine(server_url, isolation_level="AUTOCOMMIT")
|
|
||||||
try:
|
|
||||||
async with engine.connect() as conn:
|
|
||||||
await conn.execute(text(f"CREATE DATABASE {db_name}"))
|
|
||||||
finally:
|
|
||||||
await engine.dispose()
|
|
||||||
|
|
||||||
|
|
||||||
async def cleanup_tables(
|
|
||||||
session: AsyncSession,
|
|
||||||
base: type[DeclarativeBase],
|
|
||||||
) -> None:
|
|
||||||
"""Truncate all tables for fast between-test cleanup.
|
|
||||||
|
|
||||||
Executes a single ``TRUNCATE … RESTART IDENTITY CASCADE`` statement
|
|
||||||
across every table in *base*'s metadata, which is significantly faster
|
|
||||||
than dropping and re-creating tables between tests.
|
|
||||||
|
|
||||||
This is a no-op when the metadata contains no tables.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
session: An active async database session.
|
|
||||||
base: SQLAlchemy DeclarativeBase class containing model metadata.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
```python
|
|
||||||
@pytest.fixture
|
|
||||||
async def db_session(worker_db_url):
|
|
||||||
async with create_db_session(worker_db_url, Base) as session:
|
|
||||||
yield session
|
|
||||||
await cleanup_tables(session, Base)
|
|
||||||
```
|
|
||||||
"""
|
|
||||||
tables = base.metadata.sorted_tables
|
|
||||||
if not tables:
|
|
||||||
return
|
|
||||||
|
|
||||||
table_names = ", ".join(f'"{t.name}"' for t in tables)
|
|
||||||
await session.execute(text(f"TRUNCATE {table_names} RESTART IDENTITY CASCADE"))
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
|
|
||||||
_M = TypeVar("_M", bound=DeclarativeBase)
|
_M = TypeVar("_M", bound=DeclarativeBase)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ def init_metrics(
|
|||||||
"""
|
"""
|
||||||
for provider in registry.get_providers():
|
for provider in registry.get_providers():
|
||||||
logger.debug("Initialising metric provider '%s'", provider.name)
|
logger.debug("Initialising metric provider '%s'", provider.name)
|
||||||
registry._instances[provider.name] = provider.func()
|
provider.func()
|
||||||
|
|
||||||
# Partition collectors and cache env check at startup — both are stable for the app lifetime.
|
# Partition collectors and cache env check at startup — both are stable for the app lifetime.
|
||||||
async_collectors = [
|
async_collectors = [
|
||||||
|
|||||||
@@ -19,11 +19,31 @@ class Metric:
|
|||||||
|
|
||||||
|
|
||||||
class MetricsRegistry:
|
class MetricsRegistry:
|
||||||
"""Registry for managing Prometheus metric providers and collectors."""
|
"""Registry for managing Prometheus metric providers and collectors.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```python
|
||||||
|
from prometheus_client import Counter, Gauge
|
||||||
|
from fastapi_toolsets.metrics import MetricsRegistry
|
||||||
|
|
||||||
|
metrics = MetricsRegistry()
|
||||||
|
|
||||||
|
@metrics.register
|
||||||
|
def http_requests():
|
||||||
|
return Counter("http_requests_total", "Total HTTP requests", ["method", "status"])
|
||||||
|
|
||||||
|
@metrics.register(name="db_pool")
|
||||||
|
def database_pool_size():
|
||||||
|
return Gauge("db_pool_size", "Database connection pool size")
|
||||||
|
|
||||||
|
@metrics.register(collect=True)
|
||||||
|
def collect_queue_depth(gauge=Gauge("queue_depth", "Current queue depth")):
|
||||||
|
gauge.set(get_current_queue_depth())
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self._metrics: dict[str, Metric] = {}
|
self._metrics: dict[str, Metric] = {}
|
||||||
self._instances: dict[str, Any] = {}
|
|
||||||
|
|
||||||
def register(
|
def register(
|
||||||
self,
|
self,
|
||||||
@@ -41,6 +61,17 @@ class MetricsRegistry:
|
|||||||
name: Metric name (defaults to function name).
|
name: Metric name (defaults to function name).
|
||||||
collect: If ``True``, the function is called on every scrape.
|
collect: If ``True``, the function is called on every scrape.
|
||||||
If ``False`` (default), called once at init time.
|
If ``False`` (default), called once at init time.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```python
|
||||||
|
@metrics.register
|
||||||
|
def my_counter():
|
||||||
|
return Counter("my_counter", "A counter")
|
||||||
|
|
||||||
|
@metrics.register(collect=True, name="queue")
|
||||||
|
def collect_queue_depth():
|
||||||
|
gauge.set(compute_depth())
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
|
def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
|
||||||
@@ -56,25 +87,6 @@ class MetricsRegistry:
|
|||||||
return decorator(func)
|
return decorator(func)
|
||||||
return decorator
|
return decorator
|
||||||
|
|
||||||
def get(self, name: str) -> Any:
|
|
||||||
"""Return the metric instance created by a provider.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
name: The metric name (defaults to the provider function name).
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
KeyError: If the metric name is unknown or ``init_metrics`` has not
|
|
||||||
been called yet.
|
|
||||||
"""
|
|
||||||
if name not in self._instances:
|
|
||||||
if name in self._metrics:
|
|
||||||
raise KeyError(
|
|
||||||
f"Metric '{name}' exists but has not been initialized yet. "
|
|
||||||
"Ensure init_metrics() has been called before accessing metric instances."
|
|
||||||
)
|
|
||||||
raise KeyError(f"Unknown metric '{name}'.")
|
|
||||||
return self._instances[name]
|
|
||||||
|
|
||||||
def include_registry(self, registry: "MetricsRegistry") -> None:
|
def include_registry(self, registry: "MetricsRegistry") -> None:
|
||||||
"""Include another :class:`MetricsRegistry` into this one.
|
"""Include another :class:`MetricsRegistry` into this one.
|
||||||
|
|
||||||
@@ -83,6 +95,18 @@ class MetricsRegistry:
|
|||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ValueError: If a metric name already exists in the current registry.
|
ValueError: If a metric name already exists in the current registry.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```python
|
||||||
|
main = MetricsRegistry()
|
||||||
|
sub = MetricsRegistry()
|
||||||
|
|
||||||
|
@sub.register
|
||||||
|
def sub_metric():
|
||||||
|
return Counter("sub_total", "Sub counter")
|
||||||
|
|
||||||
|
main.include_registry(sub)
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
for metric_name, definition in registry._metrics.items():
|
for metric_name, definition in registry._metrics.items():
|
||||||
if metric_name in self._metrics:
|
if metric_name in self._metrics:
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
"""Pytest helper utilities for FastAPI testing."""
|
"""Pytest helper utilities for FastAPI testing."""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import warnings
|
|
||||||
from collections.abc import AsyncGenerator, Callable
|
from collections.abc import AsyncGenerator, Callable
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from httpx import ASGITransport, AsyncClient
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
from sqlalchemy import text
|
||||||
from sqlalchemy.engine import make_url
|
from sqlalchemy.engine import make_url
|
||||||
from sqlalchemy.ext.asyncio import (
|
from sqlalchemy.ext.asyncio import (
|
||||||
AsyncSession,
|
AsyncSession,
|
||||||
@@ -15,134 +15,7 @@ from sqlalchemy.ext.asyncio import (
|
|||||||
)
|
)
|
||||||
from sqlalchemy.orm import DeclarativeBase
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
|
||||||
from sqlalchemy import text
|
from ..db import create_db_context
|
||||||
|
|
||||||
from ..db import (
|
|
||||||
cleanup_tables as _cleanup_tables,
|
|
||||||
create_database,
|
|
||||||
create_db_context,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def cleanup_tables(
|
|
||||||
session: AsyncSession,
|
|
||||||
base: type[DeclarativeBase],
|
|
||||||
) -> None:
|
|
||||||
"""Truncate all tables for fast between-test cleanup.
|
|
||||||
|
|
||||||
.. deprecated::
|
|
||||||
Import ``cleanup_tables`` from ``fastapi_toolsets.db`` instead.
|
|
||||||
This re-export will be removed in v3.0.0.
|
|
||||||
"""
|
|
||||||
warnings.warn(
|
|
||||||
"Importing cleanup_tables from fastapi_toolsets.pytest is deprecated "
|
|
||||||
"and will be removed in v3.0.0. "
|
|
||||||
"Use 'from fastapi_toolsets.db import cleanup_tables' instead.",
|
|
||||||
DeprecationWarning,
|
|
||||||
stacklevel=2,
|
|
||||||
)
|
|
||||||
await _cleanup_tables(session=session, base=base)
|
|
||||||
|
|
||||||
|
|
||||||
def _get_xdist_worker(default_test_db: str) -> str:
|
|
||||||
"""Return the pytest-xdist worker name, or *default_test_db* when not running under xdist.
|
|
||||||
|
|
||||||
Reads the ``PYTEST_XDIST_WORKER`` environment variable that xdist sets
|
|
||||||
automatically in each worker process (e.g. ``"gw0"``, ``"gw1"``).
|
|
||||||
When xdist is not installed or not active, the variable is absent and
|
|
||||||
*default_test_db* is returned instead.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
default_test_db: Fallback value returned when ``PYTEST_XDIST_WORKER``
|
|
||||||
is not set.
|
|
||||||
"""
|
|
||||||
return os.environ.get("PYTEST_XDIST_WORKER", default_test_db)
|
|
||||||
|
|
||||||
|
|
||||||
def worker_database_url(database_url: str, default_test_db: str) -> str:
|
|
||||||
"""Derive a per-worker database URL for pytest-xdist parallel runs.
|
|
||||||
|
|
||||||
Appends ``_{worker_name}`` to the database name so each xdist worker
|
|
||||||
operates on its own database. When not running under xdist,
|
|
||||||
``_{default_test_db}`` is appended instead.
|
|
||||||
|
|
||||||
The worker name is read from the ``PYTEST_XDIST_WORKER`` environment
|
|
||||||
variable (set automatically by xdist in each worker process).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
database_url: Original database connection URL.
|
|
||||||
default_test_db: Suffix appended to the database name when
|
|
||||||
``PYTEST_XDIST_WORKER`` is not set.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
A database URL with a worker- or default-specific database name.
|
|
||||||
"""
|
|
||||||
worker = _get_xdist_worker(default_test_db=default_test_db)
|
|
||||||
|
|
||||||
url = make_url(database_url)
|
|
||||||
url = url.set(database=f"{url.database}_{worker}")
|
|
||||||
return url.render_as_string(hide_password=False)
|
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
|
||||||
async def create_worker_database(
|
|
||||||
database_url: str,
|
|
||||||
default_test_db: str = "test_db",
|
|
||||||
) -> AsyncGenerator[str, None]:
|
|
||||||
"""Create and drop a per-worker database for pytest-xdist isolation.
|
|
||||||
|
|
||||||
Derives a worker-specific database URL using :func:`worker_database_url`,
|
|
||||||
then delegates to :func:`~fastapi_toolsets.db.create_database` to create
|
|
||||||
and drop it. Intended for use as a **session-scoped** fixture.
|
|
||||||
|
|
||||||
When running under xdist the database name is suffixed with the worker
|
|
||||||
name (e.g. ``_gw0``). Otherwise it is suffixed with *default_test_db*.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
database_url: Original database connection URL (used as the server
|
|
||||||
connection and as the base for the worker database name).
|
|
||||||
default_test_db: Suffix appended to the database name when
|
|
||||||
``PYTEST_XDIST_WORKER`` is not set. Defaults to ``"test_db"``.
|
|
||||||
|
|
||||||
Yields:
|
|
||||||
The worker-specific database URL.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
```python
|
|
||||||
from fastapi_toolsets.pytest import create_worker_database, create_db_session
|
|
||||||
|
|
||||||
DATABASE_URL = "postgresql+asyncpg://postgres:postgres@localhost/test_db"
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
|
||||||
async def worker_db_url():
|
|
||||||
async with create_worker_database(DATABASE_URL) as url:
|
|
||||||
yield url
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
async def db_session(worker_db_url):
|
|
||||||
async with create_db_session(
|
|
||||||
worker_db_url, Base, cleanup=True
|
|
||||||
) as session:
|
|
||||||
yield session
|
|
||||||
```
|
|
||||||
"""
|
|
||||||
worker_url = worker_database_url(
|
|
||||||
database_url=database_url, default_test_db=default_test_db
|
|
||||||
)
|
|
||||||
worker_db_name: str = make_url(worker_url).database # type: ignore[assignment]
|
|
||||||
|
|
||||||
engine = create_async_engine(database_url, isolation_level="AUTOCOMMIT")
|
|
||||||
try:
|
|
||||||
async with engine.connect() as conn:
|
|
||||||
await conn.execute(text(f"DROP DATABASE IF EXISTS {worker_db_name}"))
|
|
||||||
await create_database(db_name=worker_db_name, server_url=database_url)
|
|
||||||
|
|
||||||
yield worker_url
|
|
||||||
|
|
||||||
async with engine.connect() as conn:
|
|
||||||
await conn.execute(text(f"DROP DATABASE IF EXISTS {worker_db_name}"))
|
|
||||||
finally:
|
|
||||||
await engine.dispose()
|
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
@@ -283,3 +156,160 @@ async def create_db_session(
|
|||||||
await conn.run_sync(base.metadata.drop_all)
|
await conn.run_sync(base.metadata.drop_all)
|
||||||
finally:
|
finally:
|
||||||
await engine.dispose()
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def _get_xdist_worker(default_test_db: str) -> str:
|
||||||
|
"""Return the pytest-xdist worker name, or *default_test_db* when not running under xdist.
|
||||||
|
|
||||||
|
Reads the ``PYTEST_XDIST_WORKER`` environment variable that xdist sets
|
||||||
|
automatically in each worker process (e.g. ``"gw0"``, ``"gw1"``).
|
||||||
|
When xdist is not installed or not active, the variable is absent and
|
||||||
|
*default_test_db* is returned instead.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
default_test_db: Fallback value returned when ``PYTEST_XDIST_WORKER``
|
||||||
|
is not set.
|
||||||
|
"""
|
||||||
|
return os.environ.get("PYTEST_XDIST_WORKER", default_test_db)
|
||||||
|
|
||||||
|
|
||||||
|
def worker_database_url(database_url: str, default_test_db: str) -> str:
|
||||||
|
"""Derive a per-worker database URL for pytest-xdist parallel runs.
|
||||||
|
|
||||||
|
Appends ``_{worker_name}`` to the database name so each xdist worker
|
||||||
|
operates on its own database. When not running under xdist,
|
||||||
|
``_{default_test_db}`` is appended instead.
|
||||||
|
|
||||||
|
The worker name is read from the ``PYTEST_XDIST_WORKER`` environment
|
||||||
|
variable (set automatically by xdist in each worker process).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
database_url: Original database connection URL.
|
||||||
|
default_test_db: Suffix appended to the database name when
|
||||||
|
``PYTEST_XDIST_WORKER`` is not set.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A database URL with a worker- or default-specific database name.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```python
|
||||||
|
# With PYTEST_XDIST_WORKER="gw0":
|
||||||
|
url = worker_database_url(
|
||||||
|
"postgresql+asyncpg://user:pass@localhost/test_db",
|
||||||
|
default_test_db="test",
|
||||||
|
)
|
||||||
|
# "postgresql+asyncpg://user:pass@localhost/test_db_gw0"
|
||||||
|
|
||||||
|
# Without PYTEST_XDIST_WORKER:
|
||||||
|
url = worker_database_url(
|
||||||
|
"postgresql+asyncpg://user:pass@localhost/test_db",
|
||||||
|
default_test_db="test",
|
||||||
|
)
|
||||||
|
# "postgresql+asyncpg://user:pass@localhost/test_db_test"
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
worker = _get_xdist_worker(default_test_db=default_test_db)
|
||||||
|
|
||||||
|
url = make_url(database_url)
|
||||||
|
url = url.set(database=f"{url.database}_{worker}")
|
||||||
|
return url.render_as_string(hide_password=False)
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def create_worker_database(
|
||||||
|
database_url: str,
|
||||||
|
default_test_db: str = "test_db",
|
||||||
|
) -> AsyncGenerator[str, None]:
|
||||||
|
"""Create and drop a per-worker database for pytest-xdist isolation.
|
||||||
|
|
||||||
|
Intended for use as a **session-scoped** fixture. Connects to the server
|
||||||
|
using the original *database_url* (with ``AUTOCOMMIT`` isolation for DDL),
|
||||||
|
creates a dedicated database for the worker, and yields the worker-specific
|
||||||
|
URL. On cleanup the worker database is dropped.
|
||||||
|
|
||||||
|
When running under xdist the database name is suffixed with the worker
|
||||||
|
name (e.g. ``_gw0``). Otherwise it is suffixed with *default_test_db*.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
database_url: Original database connection URL.
|
||||||
|
default_test_db: Suffix appended to the database name when
|
||||||
|
``PYTEST_XDIST_WORKER`` is not set. Defaults to ``"test_db"``.
|
||||||
|
|
||||||
|
Yields:
|
||||||
|
The worker-specific database URL.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```python
|
||||||
|
from fastapi_toolsets.pytest import (
|
||||||
|
create_worker_database, create_db_session,
|
||||||
|
)
|
||||||
|
|
||||||
|
DATABASE_URL = "postgresql+asyncpg://postgres:postgres@localhost/test_db"
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
async def worker_db_url():
|
||||||
|
async with create_worker_database(DATABASE_URL) as url:
|
||||||
|
yield url
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def db_session(worker_db_url):
|
||||||
|
async with create_db_session(
|
||||||
|
worker_db_url, Base, cleanup=True
|
||||||
|
) as session:
|
||||||
|
yield session
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
worker_url = worker_database_url(
|
||||||
|
database_url=database_url, default_test_db=default_test_db
|
||||||
|
)
|
||||||
|
worker_db_name = make_url(worker_url).database
|
||||||
|
|
||||||
|
engine = create_async_engine(
|
||||||
|
database_url,
|
||||||
|
isolation_level="AUTOCOMMIT",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
async with engine.connect() as conn:
|
||||||
|
await conn.execute(text(f"DROP DATABASE IF EXISTS {worker_db_name}"))
|
||||||
|
await conn.execute(text(f"CREATE DATABASE {worker_db_name}"))
|
||||||
|
|
||||||
|
yield worker_url
|
||||||
|
|
||||||
|
async with engine.connect() as conn:
|
||||||
|
await conn.execute(text(f"DROP DATABASE IF EXISTS {worker_db_name}"))
|
||||||
|
finally:
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
async def cleanup_tables(
|
||||||
|
session: AsyncSession,
|
||||||
|
base: type[DeclarativeBase],
|
||||||
|
) -> None:
|
||||||
|
"""Truncate all tables for fast between-test cleanup.
|
||||||
|
|
||||||
|
Executes a single ``TRUNCATE … RESTART IDENTITY CASCADE`` statement
|
||||||
|
across every table in *base*'s metadata, which is significantly faster
|
||||||
|
than dropping and re-creating tables between tests.
|
||||||
|
|
||||||
|
This is a no-op when the metadata contains no tables.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: An active async database session.
|
||||||
|
base: SQLAlchemy DeclarativeBase class containing model metadata.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```python
|
||||||
|
@pytest.fixture
|
||||||
|
async def db_session(worker_db_url):
|
||||||
|
async with create_db_session(worker_db_url, Base) as session:
|
||||||
|
yield session
|
||||||
|
await cleanup_tables(session, Base)
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
tables = base.metadata.sorted_tables
|
||||||
|
if not tables:
|
||||||
|
return
|
||||||
|
|
||||||
|
table_names = ", ".join(f'"{t.name}"' for t in tables)
|
||||||
|
await session.execute(text(f"TRUNCATE {table_names} RESTART IDENTITY CASCADE"))
|
||||||
|
await session.commit()
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
"""Authentication helpers for FastAPI using Security()."""
|
||||||
|
|
||||||
|
from .base import AuthSource
|
||||||
|
from .multi import MultiAuth
|
||||||
|
from .sources import BearerTokenAuth, CookieAuth, OAuth2Auth, OpenIDAuth
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"AuthSource",
|
||||||
|
"BearerTokenAuth",
|
||||||
|
"CookieAuth",
|
||||||
|
"OAuth2Auth",
|
||||||
|
"OpenIDAuth",
|
||||||
|
"MultiAuth",
|
||||||
|
]
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
"""Abstract base class for authentication sources."""
|
||||||
|
|
||||||
|
import inspect
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
from fastapi import Request
|
||||||
|
from fastapi.security import SecurityScopes
|
||||||
|
|
||||||
|
from fastapi_toolsets.exceptions import UnauthorizedError
|
||||||
|
|
||||||
|
|
||||||
|
class AuthSource(ABC):
|
||||||
|
"""Abstract base class for authentication sources.
|
||||||
|
|
||||||
|
Subclass this to create a custom auth source that works with
|
||||||
|
:func:`~fastapi_toolsets.security.MultiAuth` and can be used directly
|
||||||
|
with :func:`fastapi.Security`.
|
||||||
|
|
||||||
|
Concrete subclasses must implement :meth:`extract` and
|
||||||
|
:meth:`authenticate`. The default :meth:`__call__` (set up in
|
||||||
|
:meth:`__init__`) wires them together for FastAPI dependency injection.
|
||||||
|
|
||||||
|
Custom subclasses with their own ``__init__`` **must** call
|
||||||
|
``super().__init__()`` to activate the default dependency behaviour::
|
||||||
|
|
||||||
|
class JWTAuth(AuthSource):
|
||||||
|
def __init__(self, secret: str, *, role: str | None = None):
|
||||||
|
super().__init__() # required
|
||||||
|
self._secret = secret
|
||||||
|
self._role = role
|
||||||
|
|
||||||
|
async def extract(self, request: Request) -> str | None:
|
||||||
|
auth = request.headers.get("Authorization", "")
|
||||||
|
if not auth.startswith("Bearer "):
|
||||||
|
return None
|
||||||
|
return auth[7:] or None
|
||||||
|
|
||||||
|
async def authenticate(self, credential: str) -> User:
|
||||||
|
payload = jwt.decode(credential, self._secret)
|
||||||
|
if self._role and payload.get("role") != self._role:
|
||||||
|
raise UnauthorizedError()
|
||||||
|
return User(**payload)
|
||||||
|
|
||||||
|
jwt_auth = JWTAuth(secret="mysecret")
|
||||||
|
|
||||||
|
@app.get("/me")
|
||||||
|
async def me(user: User = Security(jwt_auth)):
|
||||||
|
return user
|
||||||
|
|
||||||
|
# Works with MultiAuth too
|
||||||
|
multi = MultiAuth(jwt_auth, CookieAuth("session", verify_session))
|
||||||
|
|
||||||
|
.. note::
|
||||||
|
The default ``__call__`` does not register a security scheme in the
|
||||||
|
OpenAPI spec. Built-in sources (``BearerTokenAuth`` etc.) override
|
||||||
|
``__call__`` using the ``__signature__`` trick to provide a FastAPI
|
||||||
|
security scheme for Swagger UI.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
"""Set up the default FastAPI dependency signature.
|
||||||
|
|
||||||
|
Creates a closure that FastAPI can introspect to inject
|
||||||
|
:class:`fastapi.Request` and :class:`fastapi.security.SecurityScopes`.
|
||||||
|
The :meth:`__signature__` attribute is set so that ``inspect.signature``
|
||||||
|
(which FastAPI uses internally) returns the correct parameter list.
|
||||||
|
|
||||||
|
Subclasses with their own ``__init__`` must call ``super().__init__()``.
|
||||||
|
Built-in subclasses (``BearerTokenAuth`` etc.) skip this and set up
|
||||||
|
their own ``_call_fn`` / ``__signature__`` directly.
|
||||||
|
"""
|
||||||
|
source = self
|
||||||
|
|
||||||
|
async def _call(
|
||||||
|
request: Request,
|
||||||
|
security_scopes: SecurityScopes, # noqa: ARG001
|
||||||
|
) -> Any:
|
||||||
|
credential = await source.extract(request)
|
||||||
|
if credential is None:
|
||||||
|
raise UnauthorizedError()
|
||||||
|
return await source.authenticate(credential)
|
||||||
|
|
||||||
|
self._call_fn: Callable[..., Any] = _call
|
||||||
|
self.__signature__ = inspect.signature(_call)
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def extract(self, request: Request) -> str | None:
|
||||||
|
"""Extract the raw credential from the request without validating.
|
||||||
|
|
||||||
|
Returns ``None`` if no credential is present for this source.
|
||||||
|
This method must be fast and free of I/O — it is called by
|
||||||
|
:func:`~fastapi_toolsets.security.MultiAuth` for every source on
|
||||||
|
every request.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def authenticate(self, credential: str) -> Any:
|
||||||
|
"""Validate a credential and return the authenticated identity.
|
||||||
|
|
||||||
|
Should raise :class:`~fastapi_toolsets.exceptions.UnauthorizedError`
|
||||||
|
(or any exception) when the credential is invalid. The return value
|
||||||
|
is injected into the route handler as the dependency value.
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def __call__(self, **kwargs: Any) -> Any:
|
||||||
|
"""FastAPI dependency dispatch.
|
||||||
|
|
||||||
|
Delegates to the closure stored in ``_call_fn``, whose signature
|
||||||
|
(stored in ``__signature__``) tells FastAPI which parameters to inject.
|
||||||
|
"""
|
||||||
|
return await self._call_fn(**kwargs)
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
"""MultiAuth: combine multiple authentication sources into a single callable."""
|
||||||
|
|
||||||
|
import inspect
|
||||||
|
from typing import Any, cast
|
||||||
|
|
||||||
|
from fastapi import Request
|
||||||
|
from fastapi.security import SecurityScopes
|
||||||
|
|
||||||
|
from fastapi_toolsets.exceptions import UnauthorizedError
|
||||||
|
|
||||||
|
from .base import AuthSource
|
||||||
|
|
||||||
|
|
||||||
|
class MultiAuth:
|
||||||
|
"""Combine multiple authentication sources into a single callable.
|
||||||
|
|
||||||
|
Sources are tried in order; the first one whose
|
||||||
|
:meth:`~AuthSource.extract` returns a non-``None`` credential wins.
|
||||||
|
Its :meth:`~AuthSource.authenticate` is called and the result returned.
|
||||||
|
|
||||||
|
If a credential is found but the validator raises, the exception propagates
|
||||||
|
immediately — the remaining sources are **not** tried. This prevents
|
||||||
|
silent fallthrough on invalid credentials.
|
||||||
|
|
||||||
|
If no source provides a credential,
|
||||||
|
:class:`~fastapi_toolsets.exceptions.UnauthorizedError` is raised.
|
||||||
|
|
||||||
|
The :meth:`~AuthSource.extract` method of each source performs only
|
||||||
|
string matching (no I/O), so prefix-based dispatch is essentially free.
|
||||||
|
|
||||||
|
Any :class:`~AuthSource` subclass — including user-defined ones — can be
|
||||||
|
passed as a source.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
*sources: Auth source instances to try in order.
|
||||||
|
|
||||||
|
Example::
|
||||||
|
|
||||||
|
user_bearer = BearerTokenAuth(verify_user, prefix="user_")
|
||||||
|
org_bearer = BearerTokenAuth(verify_org, prefix="org_")
|
||||||
|
cookie = CookieAuth("session", verify_session)
|
||||||
|
|
||||||
|
multi = MultiAuth(user_bearer, org_bearer, cookie)
|
||||||
|
|
||||||
|
@app.get("/data")
|
||||||
|
async def data_route(user = Security(multi)):
|
||||||
|
return user
|
||||||
|
|
||||||
|
# Apply a shared requirement to all sources at once
|
||||||
|
@app.get("/admin")
|
||||||
|
async def admin_route(user = Security(multi.require(role=Role.ADMIN))):
|
||||||
|
return user
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, *sources: AuthSource) -> None:
|
||||||
|
self._sources = sources
|
||||||
|
|
||||||
|
_sources = sources
|
||||||
|
|
||||||
|
async def _call(
|
||||||
|
request: Request,
|
||||||
|
security_scopes: SecurityScopes, # noqa: ARG001
|
||||||
|
) -> Any:
|
||||||
|
for source in _sources:
|
||||||
|
credential = await source.extract(request)
|
||||||
|
if credential is not None:
|
||||||
|
return await source.authenticate(credential)
|
||||||
|
raise UnauthorizedError()
|
||||||
|
|
||||||
|
self._call_fn = _call
|
||||||
|
self.__signature__ = inspect.signature(_call)
|
||||||
|
|
||||||
|
async def __call__(self, **kwargs: Any) -> Any:
|
||||||
|
return await self._call_fn(**kwargs)
|
||||||
|
|
||||||
|
def require(self, **kwargs: Any) -> "MultiAuth":
|
||||||
|
"""Return a new :class:`MultiAuth` with kwargs forwarded to each source.
|
||||||
|
|
||||||
|
Calls ``.require(**kwargs)`` on every source that supports it. Sources
|
||||||
|
that do not implement ``.require()`` (e.g. custom :class:`~AuthSource`
|
||||||
|
subclasses) are passed through unchanged.
|
||||||
|
|
||||||
|
New kwargs are merged over each source's existing kwargs — new values
|
||||||
|
win on conflict::
|
||||||
|
|
||||||
|
multi = MultiAuth(bearer, cookie)
|
||||||
|
|
||||||
|
@app.get("/admin")
|
||||||
|
async def admin(user = Security(multi.require(role=Role.ADMIN))):
|
||||||
|
return user
|
||||||
|
"""
|
||||||
|
new_sources = tuple(
|
||||||
|
cast(Any, source).require(**kwargs)
|
||||||
|
if hasattr(source, "require")
|
||||||
|
else source
|
||||||
|
for source in self._sources
|
||||||
|
)
|
||||||
|
return MultiAuth(*new_sources)
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
"""Built-in authentication source implementations."""
|
||||||
|
|
||||||
|
from .bearer import BearerTokenAuth
|
||||||
|
from .cookie import CookieAuth
|
||||||
|
from .oauth2 import OAuth2Auth
|
||||||
|
from .openid import OpenIDAuth
|
||||||
|
|
||||||
|
__all__ = ["BearerTokenAuth", "CookieAuth", "OAuth2Auth", "OpenIDAuth"]
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
"""Bearer token authentication source."""
|
||||||
|
|
||||||
|
import inspect
|
||||||
|
import secrets
|
||||||
|
from typing import Annotated, Any, Callable
|
||||||
|
|
||||||
|
from fastapi import Depends
|
||||||
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer, SecurityScopes
|
||||||
|
|
||||||
|
from fastapi_toolsets.exceptions import UnauthorizedError
|
||||||
|
|
||||||
|
from ..base import AuthSource
|
||||||
|
|
||||||
|
|
||||||
|
class BearerTokenAuth(AuthSource):
|
||||||
|
"""Bearer token authentication source.
|
||||||
|
|
||||||
|
Wraps :class:`fastapi.security.HTTPBearer` for OpenAPI documentation.
|
||||||
|
The validator is called as ``await validator(credential, **kwargs)``
|
||||||
|
where ``kwargs`` are the extra keyword arguments provided at instantiation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
validator: Async callable that receives the credential and any extra
|
||||||
|
keyword arguments, and returns the authenticated identity (e.g. a
|
||||||
|
``User`` model). Should raise
|
||||||
|
:class:`~fastapi_toolsets.exceptions.UnauthorizedError` on failure.
|
||||||
|
prefix: Optional token prefix (e.g. ``"user_"``). If set, only tokens
|
||||||
|
whose value starts with this prefix are matched. The prefix is
|
||||||
|
**kept** in the value passed to the validator — store and compare
|
||||||
|
tokens with their prefix included. Use :meth:`generate_token` to
|
||||||
|
create correctly-prefixed tokens. This enables multiple
|
||||||
|
``BearerTokenAuth`` instances in the same app (e.g. ``"user_"``
|
||||||
|
for user tokens, ``"org_"`` for org tokens).
|
||||||
|
**kwargs: Extra keyword arguments forwarded to the validator on every
|
||||||
|
call (e.g. ``role=Role.ADMIN``).
|
||||||
|
|
||||||
|
Example::
|
||||||
|
|
||||||
|
async def verify_token(token: str, *, role: Role) -> User:
|
||||||
|
user = await db.get_by_token(token) # token includes prefix
|
||||||
|
if not user or user.role != role:
|
||||||
|
raise UnauthorizedError()
|
||||||
|
return user
|
||||||
|
|
||||||
|
bearer_admin = BearerTokenAuth(verify_token, prefix="user_", role=Role.ADMIN)
|
||||||
|
|
||||||
|
# Generate a token to store in DB and return to the client:
|
||||||
|
token = bearer_admin.generate_token() # e.g. "user_Xk3..."
|
||||||
|
|
||||||
|
@app.get("/admin")
|
||||||
|
async def admin_route(user: User = Security(bearer_admin)):
|
||||||
|
return user
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
validator: Callable[..., Any],
|
||||||
|
*,
|
||||||
|
prefix: str | None = None,
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> None:
|
||||||
|
self._validator = validator
|
||||||
|
self._prefix = prefix
|
||||||
|
self._kwargs = kwargs
|
||||||
|
self._scheme = HTTPBearer(auto_error=False)
|
||||||
|
|
||||||
|
# Capture locals for the closure — self._scheme cannot be referenced
|
||||||
|
# inside the Annotated default because annotations are evaluated at
|
||||||
|
# function-definition time (no `from __future__ import annotations`).
|
||||||
|
_scheme = self._scheme
|
||||||
|
_validator = validator
|
||||||
|
_kwargs = kwargs
|
||||||
|
_prefix = prefix
|
||||||
|
|
||||||
|
async def _call(
|
||||||
|
# security_scopes is unused in the body but its presence in the
|
||||||
|
# signature tells FastAPI to aggregate scopes from Security() calls
|
||||||
|
# up the dependency chain and expose them in the OpenAPI schema.
|
||||||
|
security_scopes: SecurityScopes, # noqa: ARG001
|
||||||
|
credentials: Annotated[
|
||||||
|
HTTPAuthorizationCredentials | None, Depends(_scheme)
|
||||||
|
] = None,
|
||||||
|
) -> Any:
|
||||||
|
if credentials is None:
|
||||||
|
raise UnauthorizedError()
|
||||||
|
token = credentials.credentials
|
||||||
|
if _prefix is not None and not token.startswith(_prefix):
|
||||||
|
raise UnauthorizedError()
|
||||||
|
return await _validator(token, **_kwargs)
|
||||||
|
|
||||||
|
# __call__ must be defined on the class (not the instance) so that
|
||||||
|
# callable(self) returns True. We expose the closure's signature via
|
||||||
|
# __signature__ so FastAPI resolves the correct sub-dependencies.
|
||||||
|
self._call_fn = _call
|
||||||
|
self.__signature__ = inspect.signature(_call)
|
||||||
|
|
||||||
|
async def __call__(self, **kwargs: Any) -> Any:
|
||||||
|
return await self._call_fn(**kwargs)
|
||||||
|
|
||||||
|
async def extract(self, request: Any) -> str | None:
|
||||||
|
"""Extract the raw credential from the request without validating.
|
||||||
|
|
||||||
|
Returns ``None`` if no ``Authorization: Bearer`` header is present,
|
||||||
|
the token is empty, or the token does not match the configured prefix.
|
||||||
|
The prefix is included in the returned value.
|
||||||
|
"""
|
||||||
|
auth = request.headers.get("Authorization", "")
|
||||||
|
if not auth.startswith("Bearer "):
|
||||||
|
return None
|
||||||
|
token = auth[7:]
|
||||||
|
if not token:
|
||||||
|
return None
|
||||||
|
if self._prefix is not None and not token.startswith(self._prefix):
|
||||||
|
return None
|
||||||
|
return token
|
||||||
|
|
||||||
|
async def authenticate(self, credential: str) -> Any:
|
||||||
|
"""Validate a credential and return the identity.
|
||||||
|
|
||||||
|
Calls ``await validator(credential, **kwargs)`` where ``kwargs`` are
|
||||||
|
the extra keyword arguments provided at instantiation.
|
||||||
|
"""
|
||||||
|
return await self._validator(credential, **self._kwargs)
|
||||||
|
|
||||||
|
def require(self, **kwargs: Any) -> "BearerTokenAuth":
|
||||||
|
"""Return a new instance with additional (or overriding) validator kwargs.
|
||||||
|
|
||||||
|
Useful for specifying per-endpoint requirements inline without
|
||||||
|
declaring a new top-level variable::
|
||||||
|
|
||||||
|
bearer = BearerTokenAuth(verify_token)
|
||||||
|
|
||||||
|
@app.get("/admin")
|
||||||
|
async def admin(user: User = Security(bearer.require(role=Role.ADMIN))):
|
||||||
|
return user
|
||||||
|
|
||||||
|
The ``prefix`` is preserved. New kwargs are merged over existing ones
|
||||||
|
(new values win on conflict).
|
||||||
|
"""
|
||||||
|
return BearerTokenAuth(
|
||||||
|
self._validator,
|
||||||
|
prefix=self._prefix,
|
||||||
|
**{**self._kwargs, **kwargs},
|
||||||
|
)
|
||||||
|
|
||||||
|
def generate_token(self, nbytes: int = 32) -> str:
|
||||||
|
"""Generate a secure random token for this auth source.
|
||||||
|
|
||||||
|
Returns a URL-safe random token. If a prefix is configured it is
|
||||||
|
prepended — the returned value is what you store in your database
|
||||||
|
and return to the client as-is.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
nbytes: Number of random bytes before base64 encoding. The
|
||||||
|
resulting string is ``ceil(nbytes * 4 / 3)`` characters
|
||||||
|
(43 chars for the default 32 bytes). Defaults to 32.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A ready-to-use token string (e.g. ``"user_Xk3..."``).
|
||||||
|
|
||||||
|
Example::
|
||||||
|
|
||||||
|
bearer = BearerTokenAuth(verify_token, prefix="user_")
|
||||||
|
token = bearer.generate_token() # "user_<random>"
|
||||||
|
await db.store_token(user_id, token)
|
||||||
|
return {"access_token": token, "token_type": "bearer"}
|
||||||
|
"""
|
||||||
|
token = secrets.token_urlsafe(nbytes)
|
||||||
|
if self._prefix is not None:
|
||||||
|
return f"{self._prefix}{token}"
|
||||||
|
return token
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
"""Cookie-based authentication source."""
|
||||||
|
|
||||||
|
import inspect
|
||||||
|
from typing import Annotated, Any, Callable
|
||||||
|
|
||||||
|
from fastapi import Depends, Request
|
||||||
|
from fastapi.security import APIKeyCookie, SecurityScopes
|
||||||
|
|
||||||
|
from fastapi_toolsets.exceptions import UnauthorizedError
|
||||||
|
|
||||||
|
from ..base import AuthSource
|
||||||
|
|
||||||
|
|
||||||
|
class CookieAuth(AuthSource):
|
||||||
|
"""Cookie-based authentication source.
|
||||||
|
|
||||||
|
Wraps :class:`fastapi.security.APIKeyCookie` for OpenAPI documentation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Cookie name to read the credential from.
|
||||||
|
validator: Async callable that receives the cookie value and any extra
|
||||||
|
keyword arguments, and returns the authenticated identity.
|
||||||
|
**kwargs: Extra keyword arguments forwarded to the validator on every
|
||||||
|
call.
|
||||||
|
|
||||||
|
Example::
|
||||||
|
|
||||||
|
async def verify_session(session_id: str) -> User:
|
||||||
|
user = await db.get_by_session(session_id)
|
||||||
|
if not user:
|
||||||
|
raise UnauthorizedError()
|
||||||
|
return user
|
||||||
|
|
||||||
|
cookie_auth = CookieAuth("session", verify_session)
|
||||||
|
|
||||||
|
@app.get("/me")
|
||||||
|
async def me(user: User = Security(cookie_auth)):
|
||||||
|
return user
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
validator: Callable[..., Any],
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> None:
|
||||||
|
self._name = name
|
||||||
|
self._validator = validator
|
||||||
|
self._kwargs = kwargs
|
||||||
|
self._scheme = APIKeyCookie(name=name, auto_error=False)
|
||||||
|
|
||||||
|
_scheme = self._scheme
|
||||||
|
_validator = validator
|
||||||
|
_kwargs = kwargs
|
||||||
|
|
||||||
|
async def _call(
|
||||||
|
security_scopes: SecurityScopes, # noqa: ARG001
|
||||||
|
value: Annotated[str | None, Depends(_scheme)] = None,
|
||||||
|
) -> Any:
|
||||||
|
if value is None:
|
||||||
|
raise UnauthorizedError()
|
||||||
|
return await _validator(value, **_kwargs)
|
||||||
|
|
||||||
|
self._call_fn = _call
|
||||||
|
self.__signature__ = inspect.signature(_call)
|
||||||
|
|
||||||
|
async def __call__(self, **kwargs: Any) -> Any:
|
||||||
|
return await self._call_fn(**kwargs)
|
||||||
|
|
||||||
|
async def extract(self, request: Request) -> str | None:
|
||||||
|
"""Extract the cookie value from the request without validating."""
|
||||||
|
return request.cookies.get(self._name)
|
||||||
|
|
||||||
|
async def authenticate(self, credential: str) -> Any:
|
||||||
|
"""Validate a credential and return the identity."""
|
||||||
|
return await self._validator(credential, **self._kwargs)
|
||||||
|
|
||||||
|
def require(self, **kwargs: Any) -> "CookieAuth":
|
||||||
|
"""Return a new instance with additional (or overriding) validator kwargs.
|
||||||
|
|
||||||
|
The cookie name is preserved. New kwargs are merged over existing ones
|
||||||
|
(new values win on conflict)::
|
||||||
|
|
||||||
|
cookie = CookieAuth("session", verify_session)
|
||||||
|
|
||||||
|
@app.get("/admin")
|
||||||
|
async def admin(user: User = Security(cookie.require(role=Role.ADMIN))):
|
||||||
|
return user
|
||||||
|
"""
|
||||||
|
return CookieAuth(
|
||||||
|
self._name,
|
||||||
|
self._validator,
|
||||||
|
**{**self._kwargs, **kwargs},
|
||||||
|
)
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
"""OAuth2 password-bearer authentication source."""
|
||||||
|
|
||||||
|
import inspect
|
||||||
|
from typing import Annotated, Any, Callable
|
||||||
|
|
||||||
|
from fastapi import Depends, Request
|
||||||
|
from fastapi.security import OAuth2PasswordBearer, SecurityScopes
|
||||||
|
|
||||||
|
from fastapi_toolsets.exceptions import UnauthorizedError
|
||||||
|
|
||||||
|
from ..base import AuthSource
|
||||||
|
|
||||||
|
|
||||||
|
class OAuth2Auth(AuthSource):
|
||||||
|
"""OAuth2 password-bearer authentication source.
|
||||||
|
|
||||||
|
Wraps :class:`fastapi.security.OAuth2PasswordBearer` for OpenAPI
|
||||||
|
documentation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
token_url: URL of the token endpoint (used in OpenAPI docs).
|
||||||
|
validator: Async callable that receives the token and any extra keyword
|
||||||
|
arguments, and returns the authenticated identity.
|
||||||
|
**kwargs: Extra keyword arguments forwarded to the validator on every
|
||||||
|
call.
|
||||||
|
|
||||||
|
Example::
|
||||||
|
|
||||||
|
async def verify_token(token: str) -> User:
|
||||||
|
...
|
||||||
|
|
||||||
|
oauth2_auth = OAuth2Auth(token_url="/token", validator=verify_token)
|
||||||
|
|
||||||
|
@app.get("/me")
|
||||||
|
async def me(user: User = Security(oauth2_auth)):
|
||||||
|
return user
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
token_url: str,
|
||||||
|
validator: Callable[..., Any],
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> None:
|
||||||
|
self._token_url = token_url
|
||||||
|
self._validator = validator
|
||||||
|
self._kwargs = kwargs
|
||||||
|
self._scheme = OAuth2PasswordBearer(tokenUrl=token_url, auto_error=False)
|
||||||
|
|
||||||
|
_scheme = self._scheme
|
||||||
|
_validator = validator
|
||||||
|
_kwargs = kwargs
|
||||||
|
|
||||||
|
async def _call(
|
||||||
|
security_scopes: SecurityScopes, # noqa: ARG001
|
||||||
|
token: Annotated[str | None, Depends(_scheme)] = None,
|
||||||
|
) -> Any:
|
||||||
|
if token is None:
|
||||||
|
raise UnauthorizedError()
|
||||||
|
return await _validator(token, **_kwargs)
|
||||||
|
|
||||||
|
self._call_fn = _call
|
||||||
|
self.__signature__ = inspect.signature(_call)
|
||||||
|
|
||||||
|
async def __call__(self, **kwargs: Any) -> Any:
|
||||||
|
return await self._call_fn(**kwargs)
|
||||||
|
|
||||||
|
async def extract(self, request: Request) -> str | None:
|
||||||
|
"""Extract the bearer token from the Authorization header."""
|
||||||
|
auth = request.headers.get("Authorization", "")
|
||||||
|
if not auth.startswith("Bearer "):
|
||||||
|
return None
|
||||||
|
token = auth[7:]
|
||||||
|
return token or None
|
||||||
|
|
||||||
|
async def authenticate(self, credential: str) -> Any:
|
||||||
|
"""Validate a credential and return the identity."""
|
||||||
|
return await self._validator(credential, **self._kwargs)
|
||||||
|
|
||||||
|
def require(self, **kwargs: Any) -> "OAuth2Auth":
|
||||||
|
"""Return a new instance with additional (or overriding) validator kwargs.
|
||||||
|
|
||||||
|
The token URL is preserved. New kwargs are merged over existing ones
|
||||||
|
(new values win on conflict)::
|
||||||
|
|
||||||
|
oauth2 = OAuth2Auth("/token", verify_token)
|
||||||
|
|
||||||
|
@app.get("/admin")
|
||||||
|
async def admin(user: User = Security(oauth2.require(role=Role.ADMIN))):
|
||||||
|
return user
|
||||||
|
"""
|
||||||
|
return OAuth2Auth(
|
||||||
|
self._token_url,
|
||||||
|
self._validator,
|
||||||
|
**{**self._kwargs, **kwargs},
|
||||||
|
)
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
"""OpenID Connect authentication source."""
|
||||||
|
|
||||||
|
import inspect
|
||||||
|
from typing import Annotated, Any, Callable
|
||||||
|
|
||||||
|
from fastapi import Depends, Request
|
||||||
|
from fastapi.security import OpenIdConnect, SecurityScopes
|
||||||
|
|
||||||
|
from fastapi_toolsets.exceptions import UnauthorizedError
|
||||||
|
|
||||||
|
from ..base import AuthSource
|
||||||
|
|
||||||
|
|
||||||
|
class OpenIDAuth(AuthSource):
|
||||||
|
"""OpenID Connect authentication source.
|
||||||
|
|
||||||
|
Wraps :class:`fastapi.security.OpenIdConnect` for OpenAPI documentation.
|
||||||
|
Token extraction reads the ``Authorization: Bearer <token>`` header;
|
||||||
|
validation is fully delegated to the user-supplied validator (use any
|
||||||
|
OIDC / JWT library such as ``authlib``, ``python-jose``, or ``PyJWT``).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
openid_connect_url: URL of the OIDC discovery document
|
||||||
|
(``/.well-known/openid-configuration``). Used only for OpenAPI
|
||||||
|
documentation — no requests are made to this URL by this class.
|
||||||
|
validator: Async callable that receives the raw bearer token and any
|
||||||
|
extra keyword arguments, and returns the authenticated identity.
|
||||||
|
Should raise :class:`~fastapi_toolsets.exceptions.UnauthorizedError`
|
||||||
|
on failure.
|
||||||
|
**kwargs: Extra keyword arguments forwarded to the validator on every
|
||||||
|
call (e.g. ``audience="my-app"``).
|
||||||
|
|
||||||
|
Example — Google::
|
||||||
|
|
||||||
|
import jwt # e.g. PyJWT or python-jose
|
||||||
|
|
||||||
|
async def verify_google_token(token: str, *, audience: str) -> User:
|
||||||
|
payload = jwt.decode(token, google_public_keys, algorithms=["RS256"],
|
||||||
|
audience=audience)
|
||||||
|
return User(email=payload["email"], name=payload["name"])
|
||||||
|
|
||||||
|
google_auth = OpenIDAuth(
|
||||||
|
"https://accounts.google.com/.well-known/openid-configuration",
|
||||||
|
verify_google_token,
|
||||||
|
audience="my-client-id",
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.get("/me")
|
||||||
|
async def me(user: User = Security(google_auth)):
|
||||||
|
return user
|
||||||
|
|
||||||
|
Multiple providers with :func:`~fastapi_toolsets.security.MultiAuth`::
|
||||||
|
|
||||||
|
multi = MultiAuth(google_auth, github_auth)
|
||||||
|
|
||||||
|
@app.get("/data")
|
||||||
|
async def data(user: User = Security(multi)):
|
||||||
|
return user
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
openid_connect_url: str,
|
||||||
|
validator: Callable[..., Any],
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> None:
|
||||||
|
self._openid_connect_url = openid_connect_url
|
||||||
|
self._validator = validator
|
||||||
|
self._kwargs = kwargs
|
||||||
|
self._scheme = OpenIdConnect(
|
||||||
|
openIdConnectUrl=openid_connect_url, auto_error=False
|
||||||
|
)
|
||||||
|
|
||||||
|
_scheme = self._scheme
|
||||||
|
_validator = validator
|
||||||
|
_kwargs = kwargs
|
||||||
|
|
||||||
|
async def _call(
|
||||||
|
security_scopes: SecurityScopes, # noqa: ARG001
|
||||||
|
# OpenIdConnect (OAuth2 base) returns the full Authorization header
|
||||||
|
# value (e.g. "Bearer <token>"), unlike OAuth2PasswordBearer which
|
||||||
|
# strips the scheme prefix.
|
||||||
|
authorization: Annotated[str | None, Depends(_scheme)] = None,
|
||||||
|
) -> Any:
|
||||||
|
if authorization is None:
|
||||||
|
raise UnauthorizedError()
|
||||||
|
if not authorization.startswith("Bearer "):
|
||||||
|
raise UnauthorizedError()
|
||||||
|
token = authorization[7:]
|
||||||
|
if not token:
|
||||||
|
raise UnauthorizedError()
|
||||||
|
return await _validator(token, **_kwargs)
|
||||||
|
|
||||||
|
self._call_fn = _call
|
||||||
|
self.__signature__ = inspect.signature(_call)
|
||||||
|
|
||||||
|
async def __call__(self, **kwargs: Any) -> Any:
|
||||||
|
return await self._call_fn(**kwargs)
|
||||||
|
|
||||||
|
async def extract(self, request: Request) -> str | None:
|
||||||
|
"""Extract the bearer token from the Authorization header."""
|
||||||
|
auth = request.headers.get("Authorization", "")
|
||||||
|
if not auth.startswith("Bearer "):
|
||||||
|
return None
|
||||||
|
return auth[7:] or None
|
||||||
|
|
||||||
|
async def authenticate(self, credential: str) -> Any:
|
||||||
|
"""Validate a credential and return the identity."""
|
||||||
|
return await self._validator(credential, **self._kwargs)
|
||||||
|
|
||||||
|
def require(self, **kwargs: Any) -> "OpenIDAuth":
|
||||||
|
"""Return a new instance with additional (or overriding) validator kwargs.
|
||||||
|
|
||||||
|
The discovery URL is preserved. New kwargs are merged over existing ones
|
||||||
|
(new values win on conflict)::
|
||||||
|
|
||||||
|
google_auth = OpenIDAuth(discovery_url, verify_google_token, audience="app")
|
||||||
|
|
||||||
|
@app.get("/admin")
|
||||||
|
async def admin(user: User = Security(google_auth.require(role=Role.ADMIN))):
|
||||||
|
return user
|
||||||
|
"""
|
||||||
|
return OpenIDAuth(
|
||||||
|
self._openid_connect_url,
|
||||||
|
self._validator,
|
||||||
|
**{**self._kwargs, **kwargs},
|
||||||
|
)
|
||||||
+1
-87
@@ -4,15 +4,10 @@ import asyncio
|
|||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from sqlalchemy import text
|
|
||||||
from sqlalchemy.engine import make_url
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
from sqlalchemy.orm import DeclarativeBase
|
|
||||||
|
|
||||||
from fastapi_toolsets.db import (
|
from fastapi_toolsets.db import (
|
||||||
LockMode,
|
LockMode,
|
||||||
cleanup_tables,
|
|
||||||
create_database,
|
|
||||||
create_db_context,
|
create_db_context,
|
||||||
create_db_dependency,
|
create_db_dependency,
|
||||||
get_transaction,
|
get_transaction,
|
||||||
@@ -20,9 +15,8 @@ from fastapi_toolsets.db import (
|
|||||||
wait_for_row_change,
|
wait_for_row_change,
|
||||||
)
|
)
|
||||||
from fastapi_toolsets.exceptions import NotFoundError
|
from fastapi_toolsets.exceptions import NotFoundError
|
||||||
from fastapi_toolsets.pytest import create_db_session
|
|
||||||
|
|
||||||
from .conftest import DATABASE_URL, Base, Role, RoleCrud, User, UserCrud
|
from .conftest import DATABASE_URL, Base, Role, RoleCrud, User
|
||||||
|
|
||||||
|
|
||||||
class TestCreateDbDependency:
|
class TestCreateDbDependency:
|
||||||
@@ -350,83 +344,3 @@ class TestWaitForRowChange:
|
|||||||
with pytest.raises(NotFoundError):
|
with pytest.raises(NotFoundError):
|
||||||
await wait_for_row_change(db_session, Role, role.id, interval=0.05)
|
await wait_for_row_change(db_session, Role, role.id, interval=0.05)
|
||||||
await delete_task
|
await delete_task
|
||||||
|
|
||||||
|
|
||||||
class TestCreateDatabase:
|
|
||||||
"""Tests for create_database."""
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_creates_database(self):
|
|
||||||
"""Database is created by create_database."""
|
|
||||||
target_url = (
|
|
||||||
make_url(DATABASE_URL)
|
|
||||||
.set(database="test_create_db_general")
|
|
||||||
.render_as_string(hide_password=False)
|
|
||||||
)
|
|
||||||
expected_db: str = make_url(target_url).database # type: ignore[assignment]
|
|
||||||
|
|
||||||
engine = create_async_engine(DATABASE_URL, isolation_level="AUTOCOMMIT")
|
|
||||||
try:
|
|
||||||
async with engine.connect() as conn:
|
|
||||||
await conn.execute(text(f"DROP DATABASE IF EXISTS {expected_db}"))
|
|
||||||
|
|
||||||
await create_database(db_name=expected_db, server_url=DATABASE_URL)
|
|
||||||
|
|
||||||
async with engine.connect() as conn:
|
|
||||||
result = await conn.execute(
|
|
||||||
text("SELECT 1 FROM pg_database WHERE datname = :name"),
|
|
||||||
{"name": expected_db},
|
|
||||||
)
|
|
||||||
assert result.scalar() == 1
|
|
||||||
|
|
||||||
# Cleanup
|
|
||||||
async with engine.connect() as conn:
|
|
||||||
await conn.execute(text(f"DROP DATABASE IF EXISTS {expected_db}"))
|
|
||||||
finally:
|
|
||||||
await engine.dispose()
|
|
||||||
|
|
||||||
|
|
||||||
class TestCleanupTables:
|
|
||||||
"""Tests for cleanup_tables helper."""
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_truncates_all_tables(self):
|
|
||||||
"""All table rows are removed after cleanup_tables."""
|
|
||||||
async with create_db_session(DATABASE_URL, Base, drop_tables=True) as session:
|
|
||||||
role = Role(id=uuid.uuid4(), name="cleanup_role")
|
|
||||||
session.add(role)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
user = User(
|
|
||||||
id=uuid.uuid4(),
|
|
||||||
username="cleanup_user",
|
|
||||||
email="cleanup@test.com",
|
|
||||||
role_id=role.id,
|
|
||||||
)
|
|
||||||
session.add(user)
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
# Verify rows exist
|
|
||||||
roles_count = await RoleCrud.count(session)
|
|
||||||
users_count = await UserCrud.count(session)
|
|
||||||
assert roles_count == 1
|
|
||||||
assert users_count == 1
|
|
||||||
|
|
||||||
await cleanup_tables(session, Base)
|
|
||||||
|
|
||||||
# Verify tables are empty
|
|
||||||
roles_count = await RoleCrud.count(session)
|
|
||||||
users_count = await UserCrud.count(session)
|
|
||||||
assert roles_count == 0
|
|
||||||
assert users_count == 0
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_noop_for_empty_metadata(self):
|
|
||||||
"""cleanup_tables does not raise when metadata has no tables."""
|
|
||||||
|
|
||||||
class EmptyBase(DeclarativeBase):
|
|
||||||
pass
|
|
||||||
|
|
||||||
async with create_db_session(DATABASE_URL, Base, drop_tables=True) as session:
|
|
||||||
# Should not raise
|
|
||||||
await cleanup_tables(session, EmptyBase)
|
|
||||||
|
|||||||
@@ -159,42 +159,6 @@ class TestMetricsRegistry:
|
|||||||
assert registry.get_all()[0].func is second
|
assert registry.get_all()[0].func is second
|
||||||
|
|
||||||
|
|
||||||
class TestGet:
|
|
||||||
"""Tests for MetricsRegistry.get method."""
|
|
||||||
|
|
||||||
def test_get_returns_instance_after_init(self):
|
|
||||||
"""get() returns the metric instance stored by init_metrics."""
|
|
||||||
app = FastAPI()
|
|
||||||
registry = MetricsRegistry()
|
|
||||||
|
|
||||||
@registry.register
|
|
||||||
def my_gauge():
|
|
||||||
return Gauge("get_test_gauge", "A test gauge")
|
|
||||||
|
|
||||||
init_metrics(app, registry)
|
|
||||||
|
|
||||||
instance = registry.get("my_gauge")
|
|
||||||
assert isinstance(instance, Gauge)
|
|
||||||
|
|
||||||
def test_get_raises_for_registered_but_not_initialized(self):
|
|
||||||
"""get() raises KeyError with an informative message when init_metrics was not called."""
|
|
||||||
registry = MetricsRegistry()
|
|
||||||
|
|
||||||
@registry.register
|
|
||||||
def my_counter():
|
|
||||||
return Counter("get_uninit_counter", "A counter")
|
|
||||||
|
|
||||||
with pytest.raises(KeyError, match="not been initialized yet"):
|
|
||||||
registry.get("my_counter")
|
|
||||||
|
|
||||||
def test_get_raises_for_unknown_name(self):
|
|
||||||
"""get() raises KeyError when the metric name is not registered at all."""
|
|
||||||
registry = MetricsRegistry()
|
|
||||||
|
|
||||||
with pytest.raises(KeyError, match="Unknown metric"):
|
|
||||||
registry.get("nonexistent")
|
|
||||||
|
|
||||||
|
|
||||||
class TestIncludeRegistry:
|
class TestIncludeRegistry:
|
||||||
"""Tests for MetricsRegistry.include_registry method."""
|
"""Tests for MetricsRegistry.include_registry method."""
|
||||||
|
|
||||||
|
|||||||
+55
-1
@@ -8,10 +8,11 @@ from httpx import AsyncClient
|
|||||||
from sqlalchemy import select, text
|
from sqlalchemy import select, text
|
||||||
from sqlalchemy.engine import make_url
|
from sqlalchemy.engine import make_url
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
|
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import DeclarativeBase, selectinload
|
||||||
|
|
||||||
from fastapi_toolsets.fixtures import Context, FixtureRegistry
|
from fastapi_toolsets.fixtures import Context, FixtureRegistry
|
||||||
from fastapi_toolsets.pytest import (
|
from fastapi_toolsets.pytest import (
|
||||||
|
cleanup_tables,
|
||||||
create_async_client,
|
create_async_client,
|
||||||
create_db_session,
|
create_db_session,
|
||||||
create_worker_database,
|
create_worker_database,
|
||||||
@@ -405,6 +406,7 @@ class TestCreateWorkerDatabase:
|
|||||||
) as url:
|
) as url:
|
||||||
assert make_url(url).database == expected_db
|
assert make_url(url).database == expected_db
|
||||||
|
|
||||||
|
# Verify the database exists while inside the context
|
||||||
engine = create_async_engine(DATABASE_URL, isolation_level="AUTOCOMMIT")
|
engine = create_async_engine(DATABASE_URL, isolation_level="AUTOCOMMIT")
|
||||||
async with engine.connect() as conn:
|
async with engine.connect() as conn:
|
||||||
result = await conn.execute(
|
result = await conn.execute(
|
||||||
@@ -414,6 +416,7 @@ class TestCreateWorkerDatabase:
|
|||||||
assert result.scalar() == 1
|
assert result.scalar() == 1
|
||||||
await engine.dispose()
|
await engine.dispose()
|
||||||
|
|
||||||
|
# After context exit the database should be dropped
|
||||||
engine = create_async_engine(DATABASE_URL, isolation_level="AUTOCOMMIT")
|
engine = create_async_engine(DATABASE_URL, isolation_level="AUTOCOMMIT")
|
||||||
async with engine.connect() as conn:
|
async with engine.connect() as conn:
|
||||||
result = await conn.execute(
|
result = await conn.execute(
|
||||||
@@ -436,6 +439,7 @@ class TestCreateWorkerDatabase:
|
|||||||
async with create_worker_database(DATABASE_URL) as url:
|
async with create_worker_database(DATABASE_URL) as url:
|
||||||
assert make_url(url).database == expected_db
|
assert make_url(url).database == expected_db
|
||||||
|
|
||||||
|
# Verify the database exists while inside the context
|
||||||
engine = create_async_engine(DATABASE_URL, isolation_level="AUTOCOMMIT")
|
engine = create_async_engine(DATABASE_URL, isolation_level="AUTOCOMMIT")
|
||||||
async with engine.connect() as conn:
|
async with engine.connect() as conn:
|
||||||
result = await conn.execute(
|
result = await conn.execute(
|
||||||
@@ -445,6 +449,7 @@ class TestCreateWorkerDatabase:
|
|||||||
assert result.scalar() == 1
|
assert result.scalar() == 1
|
||||||
await engine.dispose()
|
await engine.dispose()
|
||||||
|
|
||||||
|
# After context exit the database should be dropped
|
||||||
engine = create_async_engine(DATABASE_URL, isolation_level="AUTOCOMMIT")
|
engine = create_async_engine(DATABASE_URL, isolation_level="AUTOCOMMIT")
|
||||||
async with engine.connect() as conn:
|
async with engine.connect() as conn:
|
||||||
result = await conn.execute(
|
result = await conn.execute(
|
||||||
@@ -462,15 +467,18 @@ class TestCreateWorkerDatabase:
|
|||||||
worker_database_url(DATABASE_URL, default_test_db="unused")
|
worker_database_url(DATABASE_URL, default_test_db="unused")
|
||||||
).database
|
).database
|
||||||
|
|
||||||
|
# Pre-create the database to simulate a stale leftover
|
||||||
engine = create_async_engine(DATABASE_URL, isolation_level="AUTOCOMMIT")
|
engine = create_async_engine(DATABASE_URL, isolation_level="AUTOCOMMIT")
|
||||||
async with engine.connect() as conn:
|
async with engine.connect() as conn:
|
||||||
await conn.execute(text(f"DROP DATABASE IF EXISTS {expected_db}"))
|
await conn.execute(text(f"DROP DATABASE IF EXISTS {expected_db}"))
|
||||||
await conn.execute(text(f"CREATE DATABASE {expected_db}"))
|
await conn.execute(text(f"CREATE DATABASE {expected_db}"))
|
||||||
await engine.dispose()
|
await engine.dispose()
|
||||||
|
|
||||||
|
# Should succeed despite the database already existing
|
||||||
async with create_worker_database(DATABASE_URL) as url:
|
async with create_worker_database(DATABASE_URL) as url:
|
||||||
assert make_url(url).database == expected_db
|
assert make_url(url).database == expected_db
|
||||||
|
|
||||||
|
# Verify cleanup after context exit
|
||||||
engine = create_async_engine(DATABASE_URL, isolation_level="AUTOCOMMIT")
|
engine = create_async_engine(DATABASE_URL, isolation_level="AUTOCOMMIT")
|
||||||
async with engine.connect() as conn:
|
async with engine.connect() as conn:
|
||||||
result = await conn.execute(
|
result = await conn.execute(
|
||||||
@@ -479,3 +487,49 @@ class TestCreateWorkerDatabase:
|
|||||||
)
|
)
|
||||||
assert result.scalar() is None
|
assert result.scalar() is None
|
||||||
await engine.dispose()
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
class TestCleanupTables:
|
||||||
|
"""Tests for cleanup_tables helper."""
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_truncates_all_tables(self):
|
||||||
|
"""All table rows are removed after cleanup_tables."""
|
||||||
|
async with create_db_session(DATABASE_URL, Base, drop_tables=True) as session:
|
||||||
|
role = Role(id=uuid.uuid4(), name="cleanup_role")
|
||||||
|
session.add(role)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
user = User(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
username="cleanup_user",
|
||||||
|
email="cleanup@test.com",
|
||||||
|
role_id=role.id,
|
||||||
|
)
|
||||||
|
session.add(user)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
# Verify rows exist
|
||||||
|
roles_count = await RoleCrud.count(session)
|
||||||
|
users_count = await UserCrud.count(session)
|
||||||
|
assert roles_count == 1
|
||||||
|
assert users_count == 1
|
||||||
|
|
||||||
|
await cleanup_tables(session, Base)
|
||||||
|
|
||||||
|
# Verify tables are empty
|
||||||
|
roles_count = await RoleCrud.count(session)
|
||||||
|
users_count = await UserCrud.count(session)
|
||||||
|
assert roles_count == 0
|
||||||
|
assert users_count == 0
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_noop_for_empty_metadata(self):
|
||||||
|
"""cleanup_tables does not raise when metadata has no tables."""
|
||||||
|
|
||||||
|
class EmptyBase(DeclarativeBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async with create_db_session(DATABASE_URL, Base, drop_tables=True) as session:
|
||||||
|
# Should not raise
|
||||||
|
await cleanup_tables(session, EmptyBase)
|
||||||
|
|||||||
@@ -0,0 +1,902 @@
|
|||||||
|
"""Tests for fastapi_toolsets.security."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import FastAPI, Security
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from fastapi_toolsets.exceptions import UnauthorizedError, init_exceptions_handlers
|
||||||
|
from fastapi_toolsets.security import (
|
||||||
|
AuthSource,
|
||||||
|
BearerTokenAuth,
|
||||||
|
CookieAuth,
|
||||||
|
MultiAuth,
|
||||||
|
OAuth2Auth,
|
||||||
|
OpenIDAuth,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _app(*routes_setup_fns):
|
||||||
|
"""Build a minimal FastAPI test app with exception handlers."""
|
||||||
|
app = FastAPI()
|
||||||
|
init_exceptions_handlers(app)
|
||||||
|
for fn in routes_setup_fns:
|
||||||
|
fn(app)
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
VALID_TOKEN = "secret"
|
||||||
|
VALID_COOKIE = "session123"
|
||||||
|
|
||||||
|
|
||||||
|
async def simple_validator(credential: str) -> dict:
|
||||||
|
if credential != VALID_TOKEN:
|
||||||
|
raise UnauthorizedError()
|
||||||
|
return {"user": "alice"}
|
||||||
|
|
||||||
|
|
||||||
|
async def role_validator(credential: str, *, role: str) -> dict:
|
||||||
|
if credential != VALID_TOKEN:
|
||||||
|
raise UnauthorizedError()
|
||||||
|
return {"user": "alice", "role": role}
|
||||||
|
|
||||||
|
|
||||||
|
async def cookie_validator(value: str) -> dict:
|
||||||
|
if value != VALID_COOKIE:
|
||||||
|
raise UnauthorizedError()
|
||||||
|
return {"session": value}
|
||||||
|
|
||||||
|
|
||||||
|
class TestBearerTokenAuth:
|
||||||
|
def test_valid_token_returns_identity(self):
|
||||||
|
bearer = BearerTokenAuth(simple_validator)
|
||||||
|
|
||||||
|
def setup(app: FastAPI):
|
||||||
|
@app.get("/me")
|
||||||
|
async def me(user=Security(bearer)):
|
||||||
|
return user
|
||||||
|
|
||||||
|
client = TestClient(_app(setup))
|
||||||
|
response = client.get("/me", headers={"Authorization": f"Bearer {VALID_TOKEN}"})
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"user": "alice"}
|
||||||
|
|
||||||
|
def test_missing_header_returns_401(self):
|
||||||
|
bearer = BearerTokenAuth(simple_validator)
|
||||||
|
|
||||||
|
def setup(app: FastAPI):
|
||||||
|
@app.get("/me")
|
||||||
|
async def me(user=Security(bearer)):
|
||||||
|
return user
|
||||||
|
|
||||||
|
client = TestClient(_app(setup))
|
||||||
|
response = client.get("/me")
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
def test_invalid_token_returns_401(self):
|
||||||
|
bearer = BearerTokenAuth(simple_validator)
|
||||||
|
|
||||||
|
def setup(app: FastAPI):
|
||||||
|
@app.get("/me")
|
||||||
|
async def me(user=Security(bearer)):
|
||||||
|
return user
|
||||||
|
|
||||||
|
client = TestClient(_app(setup))
|
||||||
|
response = client.get("/me", headers={"Authorization": "Bearer wrong"})
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
def test_kwargs_forwarded_to_validator(self):
|
||||||
|
bearer = BearerTokenAuth(role_validator, role="admin")
|
||||||
|
|
||||||
|
def setup(app: FastAPI):
|
||||||
|
@app.get("/me")
|
||||||
|
async def me(user=Security(bearer)):
|
||||||
|
return user
|
||||||
|
|
||||||
|
client = TestClient(_app(setup))
|
||||||
|
response = client.get("/me", headers={"Authorization": f"Bearer {VALID_TOKEN}"})
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"user": "alice", "role": "admin"}
|
||||||
|
|
||||||
|
def test_prefix_matching_passes_full_token(self):
|
||||||
|
"""Token with matching prefix: full token (with prefix) is passed to validator."""
|
||||||
|
received: list[str] = []
|
||||||
|
|
||||||
|
async def capturing_validator(credential: str) -> dict:
|
||||||
|
received.append(credential)
|
||||||
|
return {"user": "alice"}
|
||||||
|
|
||||||
|
bearer = BearerTokenAuth(capturing_validator, prefix="user_")
|
||||||
|
|
||||||
|
def setup(app: FastAPI):
|
||||||
|
@app.get("/me")
|
||||||
|
async def me(user=Security(bearer)):
|
||||||
|
return user
|
||||||
|
|
||||||
|
client = TestClient(_app(setup))
|
||||||
|
response = client.get("/me", headers={"Authorization": "Bearer user_abc123"})
|
||||||
|
assert response.status_code == 200
|
||||||
|
# Prefix is kept — validator receives the full token as stored in DB
|
||||||
|
assert received == ["user_abc123"]
|
||||||
|
|
||||||
|
def test_prefix_mismatch_returns_401(self):
|
||||||
|
bearer = BearerTokenAuth(simple_validator, prefix="user_")
|
||||||
|
|
||||||
|
def setup(app: FastAPI):
|
||||||
|
@app.get("/me")
|
||||||
|
async def me(user=Security(bearer)):
|
||||||
|
return user
|
||||||
|
|
||||||
|
client = TestClient(_app(setup))
|
||||||
|
response = client.get("/me", headers={"Authorization": "Bearer org_abc123"})
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
# --- extract() ---
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_extract_no_header(self):
|
||||||
|
from starlette.requests import Request
|
||||||
|
|
||||||
|
bearer = BearerTokenAuth(simple_validator)
|
||||||
|
scope = {"type": "http", "method": "GET", "path": "/", "headers": []}
|
||||||
|
request = Request(scope)
|
||||||
|
assert await bearer.extract(request) is None
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_extract_empty_token(self):
|
||||||
|
from starlette.requests import Request
|
||||||
|
|
||||||
|
bearer = BearerTokenAuth(simple_validator)
|
||||||
|
scope = {
|
||||||
|
"type": "http",
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/",
|
||||||
|
"headers": [(b"authorization", b"Bearer ")],
|
||||||
|
}
|
||||||
|
request = Request(scope)
|
||||||
|
assert await bearer.extract(request) is None
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_extract_no_prefix(self):
|
||||||
|
from starlette.requests import Request
|
||||||
|
|
||||||
|
bearer = BearerTokenAuth(simple_validator)
|
||||||
|
scope = {
|
||||||
|
"type": "http",
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/",
|
||||||
|
"headers": [(b"authorization", b"Bearer mytoken")],
|
||||||
|
}
|
||||||
|
request = Request(scope)
|
||||||
|
assert await bearer.extract(request) == "mytoken"
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_extract_prefix_match(self):
|
||||||
|
from starlette.requests import Request
|
||||||
|
|
||||||
|
bearer = BearerTokenAuth(simple_validator, prefix="user_")
|
||||||
|
scope = {
|
||||||
|
"type": "http",
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/",
|
||||||
|
"headers": [(b"authorization", b"Bearer user_abc")],
|
||||||
|
}
|
||||||
|
request = Request(scope)
|
||||||
|
assert await bearer.extract(request) == "user_abc"
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_extract_prefix_no_match(self):
|
||||||
|
from starlette.requests import Request
|
||||||
|
|
||||||
|
bearer = BearerTokenAuth(simple_validator, prefix="user_")
|
||||||
|
scope = {
|
||||||
|
"type": "http",
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/",
|
||||||
|
"headers": [(b"authorization", b"Bearer org_abc")],
|
||||||
|
}
|
||||||
|
request = Request(scope)
|
||||||
|
assert await bearer.extract(request) is None
|
||||||
|
|
||||||
|
# --- generate_token() ---
|
||||||
|
|
||||||
|
def test_generate_token_no_prefix(self):
|
||||||
|
bearer = BearerTokenAuth(simple_validator)
|
||||||
|
token = bearer.generate_token()
|
||||||
|
assert isinstance(token, str)
|
||||||
|
assert len(token) > 0
|
||||||
|
|
||||||
|
def test_generate_token_with_prefix(self):
|
||||||
|
bearer = BearerTokenAuth(simple_validator, prefix="user_")
|
||||||
|
token = bearer.generate_token()
|
||||||
|
assert token.startswith("user_")
|
||||||
|
|
||||||
|
def test_generate_token_uniqueness(self):
|
||||||
|
bearer = BearerTokenAuth(simple_validator)
|
||||||
|
assert bearer.generate_token() != bearer.generate_token()
|
||||||
|
|
||||||
|
def test_generate_token_is_valid_credential(self):
|
||||||
|
"""A generated token (with prefix) is accepted by the same auth source."""
|
||||||
|
stored: list[str] = []
|
||||||
|
|
||||||
|
async def storing_validator(credential: str) -> dict:
|
||||||
|
stored.append(credential)
|
||||||
|
return {"token": credential}
|
||||||
|
|
||||||
|
bearer = BearerTokenAuth(storing_validator, prefix="user_")
|
||||||
|
token = bearer.generate_token()
|
||||||
|
|
||||||
|
def setup(app: FastAPI):
|
||||||
|
@app.get("/me")
|
||||||
|
async def me(user=Security(bearer)):
|
||||||
|
return user
|
||||||
|
|
||||||
|
client = TestClient(_app(setup))
|
||||||
|
response = client.get("/me", headers={"Authorization": f"Bearer {token}"})
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert stored == [token]
|
||||||
|
|
||||||
|
|
||||||
|
class TestCookieAuth:
|
||||||
|
def test_valid_cookie_returns_identity(self):
|
||||||
|
cookie_auth = CookieAuth("session", cookie_validator)
|
||||||
|
|
||||||
|
def setup(app: FastAPI):
|
||||||
|
@app.get("/me")
|
||||||
|
async def me(user=Security(cookie_auth)):
|
||||||
|
return user
|
||||||
|
|
||||||
|
client = TestClient(_app(setup))
|
||||||
|
response = client.get("/me", cookies={"session": VALID_COOKIE})
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"session": VALID_COOKIE}
|
||||||
|
|
||||||
|
def test_missing_cookie_returns_401(self):
|
||||||
|
cookie_auth = CookieAuth("session", cookie_validator)
|
||||||
|
|
||||||
|
def setup(app: FastAPI):
|
||||||
|
@app.get("/me")
|
||||||
|
async def me(user=Security(cookie_auth)):
|
||||||
|
return user
|
||||||
|
|
||||||
|
client = TestClient(_app(setup))
|
||||||
|
response = client.get("/me")
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
def test_invalid_cookie_returns_401(self):
|
||||||
|
cookie_auth = CookieAuth("session", cookie_validator)
|
||||||
|
|
||||||
|
def setup(app: FastAPI):
|
||||||
|
@app.get("/me")
|
||||||
|
async def me(user=Security(cookie_auth)):
|
||||||
|
return user
|
||||||
|
|
||||||
|
client = TestClient(_app(setup))
|
||||||
|
response = client.get("/me", cookies={"session": "wrong"})
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
def test_kwargs_forwarded_to_validator(self):
|
||||||
|
async def session_validator(value: str, *, scope: str) -> dict:
|
||||||
|
if value != VALID_COOKIE:
|
||||||
|
raise UnauthorizedError()
|
||||||
|
return {"session": value, "scope": scope}
|
||||||
|
|
||||||
|
cookie_auth = CookieAuth("session", session_validator, scope="read")
|
||||||
|
|
||||||
|
def setup(app: FastAPI):
|
||||||
|
@app.get("/me")
|
||||||
|
async def me(user=Security(cookie_auth)):
|
||||||
|
return user
|
||||||
|
|
||||||
|
client = TestClient(_app(setup))
|
||||||
|
response = client.get("/me", cookies={"session": VALID_COOKIE})
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"session": VALID_COOKIE, "scope": "read"}
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_extract_no_cookie(self):
|
||||||
|
from starlette.requests import Request
|
||||||
|
|
||||||
|
auth = CookieAuth("session", cookie_validator)
|
||||||
|
scope = {"type": "http", "method": "GET", "path": "/", "headers": []}
|
||||||
|
request = Request(scope)
|
||||||
|
assert await auth.extract(request) is None
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_extract_cookie_present(self):
|
||||||
|
from starlette.requests import Request
|
||||||
|
|
||||||
|
auth = CookieAuth("session", cookie_validator)
|
||||||
|
scope = {
|
||||||
|
"type": "http",
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/",
|
||||||
|
"headers": [(b"cookie", b"session=abc")],
|
||||||
|
}
|
||||||
|
request = Request(scope)
|
||||||
|
assert await auth.extract(request) == "abc"
|
||||||
|
|
||||||
|
|
||||||
|
class TestOAuth2Auth:
|
||||||
|
def test_valid_token_returns_identity(self):
|
||||||
|
oauth = OAuth2Auth(token_url="/token", validator=simple_validator)
|
||||||
|
|
||||||
|
def setup(app: FastAPI):
|
||||||
|
@app.get("/me")
|
||||||
|
async def me(user=Security(oauth)):
|
||||||
|
return user
|
||||||
|
|
||||||
|
client = TestClient(_app(setup))
|
||||||
|
response = client.get("/me", headers={"Authorization": f"Bearer {VALID_TOKEN}"})
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"user": "alice"}
|
||||||
|
|
||||||
|
def test_missing_token_returns_401(self):
|
||||||
|
oauth = OAuth2Auth(token_url="/token", validator=simple_validator)
|
||||||
|
|
||||||
|
def setup(app: FastAPI):
|
||||||
|
@app.get("/me")
|
||||||
|
async def me(user=Security(oauth)):
|
||||||
|
return user
|
||||||
|
|
||||||
|
client = TestClient(_app(setup))
|
||||||
|
response = client.get("/me")
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_extract_no_header(self):
|
||||||
|
from starlette.requests import Request
|
||||||
|
|
||||||
|
auth = OAuth2Auth("/token", simple_validator)
|
||||||
|
scope = {"type": "http", "method": "GET", "path": "/", "headers": []}
|
||||||
|
request = Request(scope)
|
||||||
|
assert await auth.extract(request) is None
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_extract_empty_token(self):
|
||||||
|
from starlette.requests import Request
|
||||||
|
|
||||||
|
auth = OAuth2Auth("/token", simple_validator)
|
||||||
|
scope = {
|
||||||
|
"type": "http",
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/",
|
||||||
|
"headers": [(b"authorization", b"Bearer ")],
|
||||||
|
}
|
||||||
|
request = Request(scope)
|
||||||
|
assert await auth.extract(request) is None
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_extract_token(self):
|
||||||
|
from starlette.requests import Request
|
||||||
|
|
||||||
|
auth = OAuth2Auth("/token", simple_validator)
|
||||||
|
scope = {
|
||||||
|
"type": "http",
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/",
|
||||||
|
"headers": [(b"authorization", b"Bearer mytoken")],
|
||||||
|
}
|
||||||
|
request = Request(scope)
|
||||||
|
assert await auth.extract(request) == "mytoken"
|
||||||
|
|
||||||
|
def test_in_multi_auth(self):
|
||||||
|
"""OAuth2Auth.authenticate() is exercised when used inside MultiAuth."""
|
||||||
|
oauth = OAuth2Auth(token_url="/token", validator=simple_validator)
|
||||||
|
multi = MultiAuth(oauth)
|
||||||
|
|
||||||
|
def setup(app: FastAPI):
|
||||||
|
@app.get("/me")
|
||||||
|
async def me(user=Security(multi)):
|
||||||
|
return user
|
||||||
|
|
||||||
|
client = TestClient(_app(setup))
|
||||||
|
response = client.get("/me", headers={"Authorization": f"Bearer {VALID_TOKEN}"})
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"user": "alice"}
|
||||||
|
|
||||||
|
|
||||||
|
class TestMultiAuth:
|
||||||
|
def test_first_source_matches(self):
|
||||||
|
bearer = BearerTokenAuth(simple_validator)
|
||||||
|
cookie = CookieAuth("session", cookie_validator)
|
||||||
|
multi = MultiAuth(bearer, cookie)
|
||||||
|
|
||||||
|
def setup(app: FastAPI):
|
||||||
|
@app.get("/me")
|
||||||
|
async def me(user=Security(multi)):
|
||||||
|
return user
|
||||||
|
|
||||||
|
client = TestClient(_app(setup))
|
||||||
|
response = client.get("/me", headers={"Authorization": f"Bearer {VALID_TOKEN}"})
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"user": "alice"}
|
||||||
|
|
||||||
|
def test_second_source_matches_when_first_absent(self):
|
||||||
|
bearer = BearerTokenAuth(simple_validator)
|
||||||
|
cookie = CookieAuth("session", cookie_validator)
|
||||||
|
multi = MultiAuth(bearer, cookie)
|
||||||
|
|
||||||
|
def setup(app: FastAPI):
|
||||||
|
@app.get("/me")
|
||||||
|
async def me(user=Security(multi)):
|
||||||
|
return user
|
||||||
|
|
||||||
|
client = TestClient(_app(setup))
|
||||||
|
# No Authorization header — falls through to cookie
|
||||||
|
response = client.get("/me", cookies={"session": VALID_COOKIE})
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"session": VALID_COOKIE}
|
||||||
|
|
||||||
|
def test_no_source_matches_returns_401(self):
|
||||||
|
bearer = BearerTokenAuth(simple_validator)
|
||||||
|
cookie = CookieAuth("session", cookie_validator)
|
||||||
|
multi = MultiAuth(bearer, cookie)
|
||||||
|
|
||||||
|
def setup(app: FastAPI):
|
||||||
|
@app.get("/me")
|
||||||
|
async def me(user=Security(multi)):
|
||||||
|
return user
|
||||||
|
|
||||||
|
client = TestClient(_app(setup))
|
||||||
|
response = client.get("/me")
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
def test_invalid_credential_does_not_fallthrough(self):
|
||||||
|
"""If a credential is found but invalid, the next source is NOT tried."""
|
||||||
|
second_called: list[bool] = []
|
||||||
|
|
||||||
|
async def tracking_validator(credential: str) -> dict:
|
||||||
|
second_called.append(True)
|
||||||
|
return {"from": "second"}
|
||||||
|
|
||||||
|
bearer = BearerTokenAuth(simple_validator) # raises on wrong token
|
||||||
|
cookie = CookieAuth("session", tracking_validator)
|
||||||
|
multi = MultiAuth(bearer, cookie)
|
||||||
|
|
||||||
|
def setup(app: FastAPI):
|
||||||
|
@app.get("/me")
|
||||||
|
async def me(user=Security(multi)):
|
||||||
|
return user
|
||||||
|
|
||||||
|
client = TestClient(_app(setup))
|
||||||
|
# Bearer credential present but wrong — should NOT try cookie
|
||||||
|
response = client.get(
|
||||||
|
"/me",
|
||||||
|
headers={"Authorization": "Bearer wrong"},
|
||||||
|
cookies={"session": VALID_COOKIE},
|
||||||
|
)
|
||||||
|
assert response.status_code == 401
|
||||||
|
assert second_called == [] # cookie validator was never called
|
||||||
|
|
||||||
|
def test_prefix_routes_to_correct_source(self):
|
||||||
|
"""Prefix-based dispatch: only the matching source's validator is called."""
|
||||||
|
user_calls: list[str] = []
|
||||||
|
org_calls: list[str] = []
|
||||||
|
|
||||||
|
async def user_validator(credential: str) -> dict:
|
||||||
|
user_calls.append(credential)
|
||||||
|
return {"type": "user", "id": credential}
|
||||||
|
|
||||||
|
async def org_validator(credential: str) -> dict:
|
||||||
|
org_calls.append(credential)
|
||||||
|
return {"type": "org", "id": credential}
|
||||||
|
|
||||||
|
user_bearer = BearerTokenAuth(user_validator, prefix="user_")
|
||||||
|
org_bearer = BearerTokenAuth(org_validator, prefix="org_")
|
||||||
|
multi = MultiAuth(user_bearer, org_bearer)
|
||||||
|
|
||||||
|
def setup(app: FastAPI):
|
||||||
|
@app.get("/me")
|
||||||
|
async def me(user=Security(multi)):
|
||||||
|
return user
|
||||||
|
|
||||||
|
client = TestClient(_app(setup))
|
||||||
|
|
||||||
|
response = client.get("/me", headers={"Authorization": "Bearer user_alice"})
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"type": "user", "id": "user_alice"}
|
||||||
|
assert user_calls == ["user_alice"]
|
||||||
|
assert org_calls == []
|
||||||
|
|
||||||
|
user_calls.clear()
|
||||||
|
|
||||||
|
response = client.get("/me", headers={"Authorization": "Bearer org_acme"})
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"type": "org", "id": "org_acme"}
|
||||||
|
assert user_calls == []
|
||||||
|
assert org_calls == ["org_acme"]
|
||||||
|
|
||||||
|
def test_require_returns_new_multi_auth(self):
|
||||||
|
from fastapi_toolsets.security.multi import MultiAuth as MultiAuthClass
|
||||||
|
|
||||||
|
bearer = BearerTokenAuth(role_validator)
|
||||||
|
multi = MultiAuth(bearer)
|
||||||
|
derived = multi.require(role="admin")
|
||||||
|
assert isinstance(derived, MultiAuthClass)
|
||||||
|
assert derived is not multi
|
||||||
|
|
||||||
|
def test_require_forwards_kwargs_to_sources(self):
|
||||||
|
"""multi.require() propagates to all sources that support it."""
|
||||||
|
bearer = BearerTokenAuth(role_validator)
|
||||||
|
multi = MultiAuth(bearer)
|
||||||
|
|
||||||
|
def setup(app: FastAPI):
|
||||||
|
@app.get("/admin")
|
||||||
|
async def admin(user=Security(multi.require(role="admin"))):
|
||||||
|
return user
|
||||||
|
|
||||||
|
client = TestClient(_app(setup))
|
||||||
|
response = client.get(
|
||||||
|
"/admin", headers={"Authorization": f"Bearer {VALID_TOKEN}"}
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"user": "alice", "role": "admin"}
|
||||||
|
|
||||||
|
def test_require_skips_sources_without_require(self):
|
||||||
|
"""Sources without require() are passed through unchanged."""
|
||||||
|
header_auth = _HeaderAuth(secret="s3cr3t")
|
||||||
|
multi = MultiAuth(header_auth)
|
||||||
|
derived = multi.require(role="admin")
|
||||||
|
assert derived._sources[0] is header_auth
|
||||||
|
|
||||||
|
def test_require_does_not_mutate_original(self):
|
||||||
|
bearer = BearerTokenAuth(role_validator, role="user")
|
||||||
|
multi = MultiAuth(bearer)
|
||||||
|
multi.require(role="admin")
|
||||||
|
assert bearer._kwargs == {"role": "user"}
|
||||||
|
|
||||||
|
def test_require_mixed_sources(self):
|
||||||
|
"""require() applies to sources with require(), skips those without."""
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
|
bearer = BearerTokenAuth(role_validator)
|
||||||
|
header_auth = _HeaderAuth(secret="s3cr3t")
|
||||||
|
multi = MultiAuth(bearer, header_auth)
|
||||||
|
derived = multi.require(role="admin")
|
||||||
|
# bearer got require() applied, header_auth passed through
|
||||||
|
assert cast(BearerTokenAuth, derived._sources[0])._kwargs == {"role": "admin"}
|
||||||
|
assert derived._sources[1] is header_auth
|
||||||
|
|
||||||
|
|
||||||
|
class TestRequire:
|
||||||
|
def test_bearer_require_forwards_kwargs(self):
|
||||||
|
"""require() creates a new instance that passes merged kwargs to validator."""
|
||||||
|
bearer = BearerTokenAuth(role_validator)
|
||||||
|
|
||||||
|
def setup(app: FastAPI):
|
||||||
|
@app.get("/admin")
|
||||||
|
async def admin(user=Security(bearer.require(role="admin"))):
|
||||||
|
return user
|
||||||
|
|
||||||
|
client = TestClient(_app(setup))
|
||||||
|
response = client.get(
|
||||||
|
"/admin", headers={"Authorization": f"Bearer {VALID_TOKEN}"}
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"user": "alice", "role": "admin"}
|
||||||
|
|
||||||
|
def test_bearer_require_overrides_existing_kwarg(self):
|
||||||
|
"""require() kwargs override kwargs set at instantiation."""
|
||||||
|
bearer = BearerTokenAuth(role_validator, role="user")
|
||||||
|
|
||||||
|
def setup(app: FastAPI):
|
||||||
|
@app.get("/admin")
|
||||||
|
async def admin(user=Security(bearer.require(role="admin"))):
|
||||||
|
return user
|
||||||
|
|
||||||
|
client = TestClient(_app(setup))
|
||||||
|
response = client.get(
|
||||||
|
"/admin", headers={"Authorization": f"Bearer {VALID_TOKEN}"}
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["role"] == "admin"
|
||||||
|
|
||||||
|
def test_bearer_require_preserves_prefix(self):
|
||||||
|
"""require() keeps the prefix of the original instance."""
|
||||||
|
bearer = BearerTokenAuth(role_validator, prefix="user_")
|
||||||
|
derived = bearer.require(role="admin")
|
||||||
|
assert derived._prefix == "user_"
|
||||||
|
|
||||||
|
def test_bearer_require_does_not_mutate_original(self):
|
||||||
|
"""require() returns a new instance — original kwargs are unchanged."""
|
||||||
|
bearer = BearerTokenAuth(role_validator, role="user")
|
||||||
|
bearer.require(role="admin")
|
||||||
|
assert bearer._kwargs == {"role": "user"}
|
||||||
|
|
||||||
|
def test_cookie_require_forwards_kwargs(self):
|
||||||
|
async def scoped_validator(value: str, *, scope: str) -> dict:
|
||||||
|
if value != VALID_COOKIE:
|
||||||
|
raise UnauthorizedError()
|
||||||
|
return {"session": value, "scope": scope}
|
||||||
|
|
||||||
|
cookie = CookieAuth("session", scoped_validator)
|
||||||
|
|
||||||
|
def setup(app: FastAPI):
|
||||||
|
@app.get("/admin")
|
||||||
|
async def admin(user=Security(cookie.require(scope="admin"))):
|
||||||
|
return user
|
||||||
|
|
||||||
|
client = TestClient(_app(setup))
|
||||||
|
response = client.get("/admin", cookies={"session": VALID_COOKIE})
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"session": VALID_COOKIE, "scope": "admin"}
|
||||||
|
|
||||||
|
def test_cookie_require_preserves_name(self):
|
||||||
|
cookie = CookieAuth("session", cookie_validator)
|
||||||
|
derived = cookie.require(scope="admin")
|
||||||
|
assert derived._name == "session"
|
||||||
|
|
||||||
|
def test_oauth2_require_forwards_kwargs(self):
|
||||||
|
oauth = OAuth2Auth("/token", role_validator)
|
||||||
|
|
||||||
|
def setup(app: FastAPI):
|
||||||
|
@app.get("/admin")
|
||||||
|
async def admin(user=Security(oauth.require(role="admin"))):
|
||||||
|
return user
|
||||||
|
|
||||||
|
client = TestClient(_app(setup))
|
||||||
|
response = client.get(
|
||||||
|
"/admin", headers={"Authorization": f"Bearer {VALID_TOKEN}"}
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"user": "alice", "role": "admin"}
|
||||||
|
|
||||||
|
def test_oauth2_require_preserves_token_url(self):
|
||||||
|
oauth = OAuth2Auth("/token", simple_validator)
|
||||||
|
derived = oauth.require(role="admin")
|
||||||
|
assert derived._token_url == "/token"
|
||||||
|
|
||||||
|
def test_bearer_require_in_multi_auth(self):
|
||||||
|
"""require() instances work seamlessly inside MultiAuth."""
|
||||||
|
PREFIXED_TOKEN = f"user_{VALID_TOKEN}"
|
||||||
|
|
||||||
|
async def prefixed_role_validator(credential: str, *, role: str) -> dict:
|
||||||
|
if credential != PREFIXED_TOKEN:
|
||||||
|
raise UnauthorizedError()
|
||||||
|
return {"user": "alice", "role": role}
|
||||||
|
|
||||||
|
bearer = BearerTokenAuth(prefixed_role_validator, prefix="user_")
|
||||||
|
multi = MultiAuth(bearer.require(role="admin"))
|
||||||
|
|
||||||
|
def setup(app: FastAPI):
|
||||||
|
@app.get("/admin")
|
||||||
|
async def admin(user=Security(multi)):
|
||||||
|
return user
|
||||||
|
|
||||||
|
client = TestClient(_app(setup))
|
||||||
|
response = client.get(
|
||||||
|
"/admin", headers={"Authorization": f"Bearer {PREFIXED_TOKEN}"}
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"user": "alice", "role": "admin"}
|
||||||
|
|
||||||
|
|
||||||
|
class TestOpenIDAuth:
|
||||||
|
DISCOVERY_URL = "https://accounts.example.com/.well-known/openid-configuration"
|
||||||
|
|
||||||
|
def test_valid_token_returns_identity(self):
|
||||||
|
oidc = OpenIDAuth(self.DISCOVERY_URL, simple_validator)
|
||||||
|
|
||||||
|
def setup(app: FastAPI):
|
||||||
|
@app.get("/me")
|
||||||
|
async def me(user=Security(oidc)):
|
||||||
|
return user
|
||||||
|
|
||||||
|
client = TestClient(_app(setup))
|
||||||
|
response = client.get("/me", headers={"Authorization": f"Bearer {VALID_TOKEN}"})
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"user": "alice"}
|
||||||
|
|
||||||
|
def test_missing_token_returns_401(self):
|
||||||
|
oidc = OpenIDAuth(self.DISCOVERY_URL, simple_validator)
|
||||||
|
|
||||||
|
def setup(app: FastAPI):
|
||||||
|
@app.get("/me")
|
||||||
|
async def me(user=Security(oidc)):
|
||||||
|
return user
|
||||||
|
|
||||||
|
client = TestClient(_app(setup))
|
||||||
|
response = client.get("/me")
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
def test_invalid_token_returns_401(self):
|
||||||
|
oidc = OpenIDAuth(self.DISCOVERY_URL, simple_validator)
|
||||||
|
|
||||||
|
def setup(app: FastAPI):
|
||||||
|
@app.get("/me")
|
||||||
|
async def me(user=Security(oidc)):
|
||||||
|
return user
|
||||||
|
|
||||||
|
client = TestClient(_app(setup))
|
||||||
|
response = client.get("/me", headers={"Authorization": "Bearer wrong"})
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
def test_kwargs_forwarded_to_validator(self):
|
||||||
|
oidc = OpenIDAuth(self.DISCOVERY_URL, role_validator, role="admin")
|
||||||
|
|
||||||
|
def setup(app: FastAPI):
|
||||||
|
@app.get("/me")
|
||||||
|
async def me(user=Security(oidc)):
|
||||||
|
return user
|
||||||
|
|
||||||
|
client = TestClient(_app(setup))
|
||||||
|
response = client.get("/me", headers={"Authorization": f"Bearer {VALID_TOKEN}"})
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"user": "alice", "role": "admin"}
|
||||||
|
|
||||||
|
def test_require_forwards_kwargs(self):
|
||||||
|
oidc = OpenIDAuth(self.DISCOVERY_URL, role_validator)
|
||||||
|
|
||||||
|
def setup(app: FastAPI):
|
||||||
|
@app.get("/admin")
|
||||||
|
async def admin(user=Security(oidc.require(role="admin"))):
|
||||||
|
return user
|
||||||
|
|
||||||
|
client = TestClient(_app(setup))
|
||||||
|
response = client.get(
|
||||||
|
"/admin", headers={"Authorization": f"Bearer {VALID_TOKEN}"}
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"user": "alice", "role": "admin"}
|
||||||
|
|
||||||
|
def test_require_preserves_discovery_url(self):
|
||||||
|
oidc = OpenIDAuth(self.DISCOVERY_URL, simple_validator)
|
||||||
|
derived = oidc.require(role="admin")
|
||||||
|
assert derived._openid_connect_url == self.DISCOVERY_URL
|
||||||
|
|
||||||
|
def test_require_does_not_mutate_original(self):
|
||||||
|
oidc = OpenIDAuth(self.DISCOVERY_URL, role_validator, role="user")
|
||||||
|
oidc.require(role="admin")
|
||||||
|
assert oidc._kwargs == {"role": "user"}
|
||||||
|
|
||||||
|
def test_is_auth_source(self):
|
||||||
|
oidc = OpenIDAuth(self.DISCOVERY_URL, simple_validator)
|
||||||
|
assert isinstance(oidc, AuthSource)
|
||||||
|
|
||||||
|
def test_in_multi_auth(self):
|
||||||
|
"""OpenIDAuth works seamlessly inside MultiAuth."""
|
||||||
|
oidc = OpenIDAuth(self.DISCOVERY_URL, simple_validator)
|
||||||
|
multi = MultiAuth(oidc)
|
||||||
|
|
||||||
|
def setup(app: FastAPI):
|
||||||
|
@app.get("/me")
|
||||||
|
async def me(user=Security(multi)):
|
||||||
|
return user
|
||||||
|
|
||||||
|
client = TestClient(_app(setup))
|
||||||
|
response = client.get("/me", headers={"Authorization": f"Bearer {VALID_TOKEN}"})
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"user": "alice"}
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_extract_no_header(self):
|
||||||
|
from starlette.requests import Request
|
||||||
|
|
||||||
|
oidc = OpenIDAuth(self.DISCOVERY_URL, simple_validator)
|
||||||
|
scope = {"type": "http", "method": "GET", "path": "/", "headers": []}
|
||||||
|
request = Request(scope)
|
||||||
|
assert await oidc.extract(request) is None
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_extract_empty_token(self):
|
||||||
|
from starlette.requests import Request
|
||||||
|
|
||||||
|
oidc = OpenIDAuth(self.DISCOVERY_URL, simple_validator)
|
||||||
|
scope = {
|
||||||
|
"type": "http",
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/",
|
||||||
|
"headers": [(b"authorization", b"Bearer ")],
|
||||||
|
}
|
||||||
|
request = Request(scope)
|
||||||
|
assert await oidc.extract(request) is None
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_extract_token(self):
|
||||||
|
from starlette.requests import Request
|
||||||
|
|
||||||
|
oidc = OpenIDAuth(self.DISCOVERY_URL, simple_validator)
|
||||||
|
scope = {
|
||||||
|
"type": "http",
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/",
|
||||||
|
"headers": [(b"authorization", b"Bearer mytoken")],
|
||||||
|
}
|
||||||
|
request = Request(scope)
|
||||||
|
assert await oidc.extract(request) == "mytoken"
|
||||||
|
|
||||||
|
|
||||||
|
# Minimal concrete subclass used only in tests below.
|
||||||
|
class _HeaderAuth(AuthSource):
|
||||||
|
"""Reads a custom X-Token header — no FastAPI security scheme."""
|
||||||
|
|
||||||
|
def __init__(self, secret: str) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self._secret = secret
|
||||||
|
|
||||||
|
async def extract(self, request) -> str | None:
|
||||||
|
return request.headers.get("X-Token") or None
|
||||||
|
|
||||||
|
async def authenticate(self, credential: str) -> dict:
|
||||||
|
if credential != self._secret:
|
||||||
|
raise UnauthorizedError()
|
||||||
|
return {"token": credential}
|
||||||
|
|
||||||
|
|
||||||
|
class TestAuthSource:
|
||||||
|
def test_cannot_instantiate_abstract_class(self):
|
||||||
|
with pytest.raises(TypeError):
|
||||||
|
AuthSource()
|
||||||
|
|
||||||
|
def test_builtin_classes_are_auth_sources(self):
|
||||||
|
bearer = BearerTokenAuth(simple_validator)
|
||||||
|
cookie = CookieAuth("session", cookie_validator)
|
||||||
|
oauth = OAuth2Auth("/token", simple_validator)
|
||||||
|
oidc = OpenIDAuth(
|
||||||
|
"https://example.com/.well-known/openid-configuration", simple_validator
|
||||||
|
)
|
||||||
|
assert isinstance(bearer, AuthSource)
|
||||||
|
assert isinstance(cookie, AuthSource)
|
||||||
|
assert isinstance(oauth, AuthSource)
|
||||||
|
assert isinstance(oidc, AuthSource)
|
||||||
|
|
||||||
|
def test_custom_source_standalone_valid(self):
|
||||||
|
"""Default __call__ wires extract + authenticate via Request injection."""
|
||||||
|
auth = _HeaderAuth(secret="s3cr3t")
|
||||||
|
|
||||||
|
def setup(app: FastAPI):
|
||||||
|
@app.get("/me")
|
||||||
|
async def me(user=Security(auth)):
|
||||||
|
return user
|
||||||
|
|
||||||
|
client = TestClient(_app(setup))
|
||||||
|
response = client.get("/me", headers={"X-Token": "s3cr3t"})
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"token": "s3cr3t"}
|
||||||
|
|
||||||
|
def test_custom_source_standalone_missing_credential(self):
|
||||||
|
auth = _HeaderAuth(secret="s3cr3t")
|
||||||
|
|
||||||
|
def setup(app: FastAPI):
|
||||||
|
@app.get("/me")
|
||||||
|
async def me(user=Security(auth)):
|
||||||
|
return user
|
||||||
|
|
||||||
|
client = TestClient(_app(setup))
|
||||||
|
response = client.get("/me") # no X-Token header
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
def test_custom_source_standalone_invalid_credential(self):
|
||||||
|
auth = _HeaderAuth(secret="s3cr3t")
|
||||||
|
|
||||||
|
def setup(app: FastAPI):
|
||||||
|
@app.get("/me")
|
||||||
|
async def me(user=Security(auth)):
|
||||||
|
return user
|
||||||
|
|
||||||
|
client = TestClient(_app(setup))
|
||||||
|
response = client.get("/me", headers={"X-Token": "wrong"})
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
def test_custom_source_in_multi_auth(self):
|
||||||
|
"""Custom AuthSource works transparently inside MultiAuth."""
|
||||||
|
header_auth = _HeaderAuth(secret="s3cr3t")
|
||||||
|
bearer = BearerTokenAuth(simple_validator)
|
||||||
|
multi = MultiAuth(bearer, header_auth)
|
||||||
|
|
||||||
|
def setup(app: FastAPI):
|
||||||
|
@app.get("/me")
|
||||||
|
async def me(user=Security(multi)):
|
||||||
|
return user
|
||||||
|
|
||||||
|
client = TestClient(_app(setup))
|
||||||
|
|
||||||
|
# Bearer matches first
|
||||||
|
response = client.get("/me", headers={"Authorization": f"Bearer {VALID_TOKEN}"})
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"user": "alice"}
|
||||||
|
|
||||||
|
# No bearer → falls through to custom header source
|
||||||
|
response = client.get("/me", headers={"X-Token": "s3cr3t"})
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"token": "s3cr3t"}
|
||||||
@@ -251,7 +251,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fastapi-toolsets"
|
name = "fastapi-toolsets"
|
||||||
version = "2.1.0"
|
version = "2.0.0"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "asyncpg" },
|
{ name = "asyncpg" },
|
||||||
|
|||||||
Reference in New Issue
Block a user