mirror of
https://github.com/d3vyce/fastapi-toolsets.git
synced 2026-08-13 19:42:59 +00:00
Compare commits
8
Commits
v1.2.0
..
6ea918d956
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ea918d956
|
||
|
|
e732e54518 | ||
|
|
05b5a2c876 | ||
|
|
4a020c56d1 | ||
|
|
56d365d14b
|
||
|
|
a257d85d45 | ||
|
|
117675d02f
|
||
|
|
d7ad7308c5 |
@@ -44,7 +44,7 @@ uv add "fastapi-toolsets[all]"
|
||||
|
||||
### Core
|
||||
|
||||
- **CRUD**: Generic async CRUD operations with `CrudFactory`, built-in search with relationship traversal
|
||||
- **CRUD**: Generic async CRUD operations with `CrudFactory`, built-in full-text/faceted search and Offset/Cursor pagination.
|
||||
- **Database**: Session management, transaction helpers, table locking, and polling-based row change detection
|
||||
- **Dependencies**: FastAPI dependency factories (`PathDependency`, `BodyDependency`) for automatic DB lookups from path or body parameters
|
||||
- **Fixtures**: Fixture system with dependency management, context support, and pytest integration
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
# Pagination & search
|
||||
|
||||
This example builds an articles listing endpoint that supports **offset pagination**, **cursor pagination**, **full-text search**, **faceted filtering**, and **sorting** — all from a single `CrudFactory` definition.
|
||||
|
||||
## Models
|
||||
|
||||
```python title="models.py"
|
||||
--8<-- "docs_src/examples/pagination_search/models.py"
|
||||
```
|
||||
|
||||
## Schemas
|
||||
|
||||
```python title="schemas.py"
|
||||
--8<-- "docs_src/examples/pagination_search/schemas.py"
|
||||
```
|
||||
|
||||
## Crud
|
||||
|
||||
Declare `searchable_fields`, `facet_fields`, and `order_fields` once on [`CrudFactory`](../reference/crud.md#fastapi_toolsets.crud.factory.CrudFactory). All endpoints built from this class share the same defaults and can override them per call.
|
||||
|
||||
```python title="crud.py"
|
||||
--8<-- "docs_src/examples/pagination_search/crud.py"
|
||||
```
|
||||
|
||||
## Session dependency
|
||||
|
||||
```python title="db.py"
|
||||
--8<-- "docs_src/examples/pagination_search/db.py"
|
||||
```
|
||||
|
||||
!!! info "Deploy a Postgres DB with docker"
|
||||
```bash
|
||||
docker run -d --name postgres -e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=postgres -p 5432:5432 postgres:18-alpine
|
||||
```
|
||||
|
||||
|
||||
## App
|
||||
|
||||
```python title="app.py"
|
||||
--8<-- "docs_src/examples/pagination_search/app.py"
|
||||
```
|
||||
|
||||
|
||||
## Routes
|
||||
### Offset pagination
|
||||
|
||||
Best for admin panels or any UI that needs a total item count and numbered pages.
|
||||
|
||||
```python title="routes.py:1:36"
|
||||
--8<-- "docs_src/examples/pagination_search/routes.py:1:36"
|
||||
```
|
||||
|
||||
**Example request**
|
||||
|
||||
```
|
||||
GET /articles/offset?page=2&items_per_page=10&search=fastapi&status=published&order_by=title&order=asc
|
||||
```
|
||||
|
||||
**Example response**
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "SUCCESS",
|
||||
"data": [
|
||||
{ "id": "3f47ac69-...", "title": "FastAPI tips", "status": "published", ... }
|
||||
],
|
||||
"pagination": {
|
||||
"total_count": 42,
|
||||
"page": 2,
|
||||
"items_per_page": 10,
|
||||
"has_more": true
|
||||
},
|
||||
"filter_attributes": {
|
||||
"status": ["archived", "draft", "published"],
|
||||
"name": ["backend", "frontend", "python"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`filter_attributes` always reflects the values visible **after** applying the active filters. Use it to populate filter dropdowns on the client.
|
||||
|
||||
### Cursor pagination
|
||||
|
||||
Best for feeds, infinite scroll, or any high-throughput API where offset performance degrades.
|
||||
|
||||
```python title="routes.py:39:59"
|
||||
--8<-- "docs_src/examples/pagination_search/routes.py:39:59"
|
||||
```
|
||||
|
||||
**Example request**
|
||||
|
||||
```
|
||||
GET /articles/cursor?items_per_page=10&status=published&order_by=created_at&order=desc
|
||||
```
|
||||
|
||||
**Example response**
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "SUCCESS",
|
||||
"data": [
|
||||
{ "id": "3f47ac69-...", "title": "FastAPI tips", "status": "published", ... }
|
||||
],
|
||||
"pagination": {
|
||||
"next_cursor": "eyJ2YWx1ZSI6ICIzZjQ3YWM2OS0uLi4ifQ==",
|
||||
"prev_cursor": null,
|
||||
"items_per_page": 10,
|
||||
"has_more": true
|
||||
},
|
||||
"filter_attributes": {
|
||||
"status": ["published"],
|
||||
"name": ["backend", "python"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Pass `next_cursor` as the `cursor` query parameter on the next request to advance to the next page.
|
||||
|
||||
## Search behaviour
|
||||
|
||||
Both endpoints inherit the same `searchable_fields` declared on `ArticleCrud`:
|
||||
|
||||
Search is **case-insensitive** and uses a `LIKE %query%` pattern. Pass a [`SearchConfig`](../reference/crud.md#fastapi_toolsets.crud.search.SearchConfig) instead of a plain string to control case sensitivity or switch to `match_mode="all"` (AND across all fields instead of OR).
|
||||
|
||||
```python
|
||||
from fastapi_toolsets.crud import SearchConfig
|
||||
|
||||
# Both title AND body must contain "fastapi"
|
||||
result = await ArticleCrud.offset_paginate(
|
||||
session,
|
||||
search=SearchConfig(query="fastapi", case_sensitive=True, match_mode="all"),
|
||||
search_fields=[Article.title, Article.body],
|
||||
)
|
||||
```
|
||||
+1
-1
@@ -44,7 +44,7 @@ uv add "fastapi-toolsets[all]"
|
||||
|
||||
### Core
|
||||
|
||||
- **CRUD**: Generic async CRUD operations with `CrudFactory`, built-in search with relationship traversal
|
||||
- **CRUD**: Generic async CRUD operations with `CrudFactory`, built-in full-text/faceted search and offset/cursor pagination.
|
||||
- **Database**: Session management, transaction helpers, table locking, and polling-based row change detection
|
||||
- **Dependencies**: FastAPI dependency factories (`PathDependency`, `BodyDependency`) for automatic DB lookups from path or body parameters
|
||||
- **Fixtures**: Fixture system with dependency management, context support, and pytest integration
|
||||
|
||||
+58
-4
@@ -170,7 +170,7 @@ PostCrud = CrudFactory(model=Post, cursor_column=Post.created_at)
|
||||
|
||||
Two search strategies are available, both compatible with [`offset_paginate`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud.offset_paginate) and [`cursor_paginate`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud.cursor_paginate).
|
||||
|
||||
| | Full-text search | Filter attributes |
|
||||
| | Full-text search | Faceted search |
|
||||
|---|---|---|
|
||||
| Input | Free-text string | Exact column values |
|
||||
| Relationship support | Yes | Yes |
|
||||
@@ -242,7 +242,7 @@ async def get_users(
|
||||
)
|
||||
```
|
||||
|
||||
### Filter attributes
|
||||
### Faceted search
|
||||
|
||||
!!! info "Added in `v1.2`"
|
||||
|
||||
@@ -295,6 +295,8 @@ Use `filter_by` to pass the client's chosen filter values directly — no need t
|
||||
Use [`filter_params()`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud.filter_params) to generate a dict with the facet filter values from the query parameters:
|
||||
|
||||
```python
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends
|
||||
|
||||
UserCrud = CrudFactory(
|
||||
@@ -306,7 +308,7 @@ UserCrud = CrudFactory(
|
||||
async def list_users(
|
||||
session: SessionDep,
|
||||
page: int = 1,
|
||||
filter_by: dict[str, list[str]] = Depends(UserCrud.filter_params()),
|
||||
filter_by: Annotated[dict[str, list[str]], Depends(UserCrud.filter_params())],
|
||||
) -> PaginatedResponse[UserRead]:
|
||||
return await UserCrud.offset_paginate(
|
||||
session=session,
|
||||
@@ -323,6 +325,58 @@ GET /users?status=active&country=FR → filter_by={"status": ["active"], "coun
|
||||
GET /users?role=admin&role=editor → filter_by={"role": ["admin", "editor"]} (IN clause)
|
||||
```
|
||||
|
||||
## Sorting
|
||||
|
||||
!!! info "Added in `v1.3`"
|
||||
|
||||
Declare `order_fields` on the CRUD class to expose client-driven column ordering via `order_by` and `order` query parameters.
|
||||
|
||||
```python
|
||||
UserCrud = CrudFactory(
|
||||
model=User,
|
||||
order_fields=[
|
||||
User.name,
|
||||
User.created_at,
|
||||
],
|
||||
)
|
||||
```
|
||||
|
||||
Call [`order_params()`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud.order_params) to generate a FastAPI dependency that maps the query parameters to an [`OrderByClause`](../reference/crud.md#fastapi_toolsets.crud.factory.OrderByClause) expression:
|
||||
|
||||
```python
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends
|
||||
from fastapi_toolsets.crud import OrderByClause
|
||||
|
||||
@router.get("")
|
||||
async def list_users(
|
||||
session: SessionDep,
|
||||
order_by: Annotated[OrderByClause | None, Depends(UserCrud.order_params())],
|
||||
) -> PaginatedResponse[UserRead]:
|
||||
return await UserCrud.offset_paginate(session=session, order_by=order_by)
|
||||
```
|
||||
|
||||
The dependency adds two query parameters to the endpoint:
|
||||
|
||||
| Parameter | Type |
|
||||
| ---------- | --------------- |
|
||||
| `order_by` | `str | null` |
|
||||
| `order` | `asc` or `desc` |
|
||||
|
||||
```
|
||||
GET /users?order_by=name&order=asc → ORDER BY users.name ASC
|
||||
GET /users?order_by=name&order=desc → ORDER BY users.name DESC
|
||||
```
|
||||
|
||||
An unknown `order_by` value raises [`InvalidOrderFieldError`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.InvalidOrderFieldError) (HTTP 422).
|
||||
|
||||
You can also pass `order_fields` directly to `order_params()` to override the class-level defaults without modifying them:
|
||||
|
||||
```python
|
||||
UserOrderParams = UserCrud.order_params(order_fields=[User.name])
|
||||
```
|
||||
|
||||
## Relationship loading
|
||||
|
||||
!!! info "Added in `v1.1`"
|
||||
@@ -384,7 +438,7 @@ await UserCrud.upsert(
|
||||
)
|
||||
```
|
||||
|
||||
## `schema` — typed response serialization
|
||||
## Response serialization
|
||||
|
||||
!!! info "Added in `v1.1`"
|
||||
|
||||
|
||||
+60
-15
@@ -21,30 +21,37 @@ init_exceptions_handlers(app=app)
|
||||
This registers handlers for:
|
||||
|
||||
- [`ApiException`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.ApiException) — all custom exceptions below
|
||||
- `HTTPException` — Starlette/FastAPI HTTP errors
|
||||
- `RequestValidationError` — Pydantic request validation (422)
|
||||
- `ResponseValidationError` — Pydantic response validation (422)
|
||||
- `Exception` — unhandled errors (500)
|
||||
|
||||
It also patches `app.openapi()` to replace the default Pydantic 422 schema with a structured example matching the `ErrorResponse` format.
|
||||
|
||||
## Built-in exceptions
|
||||
|
||||
| Exception | Status | Default message |
|
||||
|-----------|--------|-----------------|
|
||||
| [`UnauthorizedError`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.UnauthorizedError) | 401 | Unauthorized |
|
||||
| [`ForbiddenError`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.ForbiddenError) | 403 | Forbidden |
|
||||
| [`NotFoundError`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.NotFoundError) | 404 | Not found |
|
||||
| [`NotFoundError`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.NotFoundError) | 404 | Not Found |
|
||||
| [`ConflictError`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.ConflictError) | 409 | Conflict |
|
||||
| [`NoSearchableFieldsError`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.NoSearchableFieldsError) | 400 | No searchable fields |
|
||||
| [`InvalidFacetFilterError`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.InvalidFacetFilterError) | 400 | Invalid facet filter |
|
||||
| [`NoSearchableFieldsError`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.NoSearchableFieldsError) | 400 | No Searchable Fields |
|
||||
| [`InvalidFacetFilterError`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.InvalidFacetFilterError) | 400 | Invalid Facet Filter |
|
||||
| [`InvalidOrderFieldError`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.InvalidOrderFieldError) | 422 | Invalid Order Field |
|
||||
|
||||
### Per-instance overrides
|
||||
|
||||
All built-in exceptions accept optional keyword arguments to customise the response for a specific raise site without changing the class defaults:
|
||||
|
||||
| Argument | Effect |
|
||||
|----------|--------|
|
||||
| `detail` | Overrides both `str(exc)` (log output) and the `message` field in the response body |
|
||||
| `desc` | Overrides the `description` field |
|
||||
| `data` | Overrides the `data` field |
|
||||
|
||||
```python
|
||||
from fastapi_toolsets.exceptions import NotFoundError
|
||||
|
||||
@router.get("/users/{id}")
|
||||
async def get_user(id: int, session: AsyncSession = Depends(get_db)):
|
||||
user = await UserCrud.first(session=session, filters=[User.id == id])
|
||||
if not user:
|
||||
raise NotFoundError
|
||||
return user
|
||||
raise NotFoundError(detail="User 42 not found", desc="No user with that ID exists in the database.")
|
||||
```
|
||||
|
||||
## Custom exceptions
|
||||
@@ -58,12 +65,51 @@ from fastapi_toolsets.schemas import ApiError
|
||||
class PaymentRequiredError(ApiException):
|
||||
api_error = ApiError(
|
||||
code=402,
|
||||
msg="Payment required",
|
||||
msg="Payment Required",
|
||||
desc="Your subscription has expired.",
|
||||
err_code="PAYMENT_REQUIRED",
|
||||
err_code="BILLING-402",
|
||||
)
|
||||
```
|
||||
|
||||
!!! warning
|
||||
Subclasses that do not define `api_error` raise a `TypeError` at **class creation time**, not at raise time.
|
||||
|
||||
### Custom `__init__`
|
||||
|
||||
Override `__init__` to compute `detail`, `desc`, or `data` dynamically, then delegate to `super().__init__()`:
|
||||
|
||||
```python
|
||||
class OrderValidationError(ApiException):
|
||||
api_error = ApiError(
|
||||
code=422,
|
||||
msg="Order Validation Failed",
|
||||
desc="One or more order fields are invalid.",
|
||||
err_code="ORDER-422",
|
||||
)
|
||||
|
||||
def __init__(self, *field_errors: str) -> None:
|
||||
super().__init__(
|
||||
f"{len(field_errors)} validation error(s)",
|
||||
desc=", ".join(field_errors),
|
||||
data={"errors": [{"message": e} for e in field_errors]},
|
||||
)
|
||||
```
|
||||
|
||||
### Intermediate base classes
|
||||
|
||||
Use `abstract=True` when creating a shared base that is not meant to be raised directly:
|
||||
|
||||
```python
|
||||
class BillingError(ApiException, abstract=True):
|
||||
"""Base for all billing-related errors."""
|
||||
|
||||
class PaymentRequiredError(BillingError):
|
||||
api_error = ApiError(code=402, msg="Payment Required", desc="...", err_code="BILLING-402")
|
||||
|
||||
class SubscriptionExpiredError(BillingError):
|
||||
api_error = ApiError(code=402, msg="Subscription Expired", desc="...", err_code="BILLING-402-EXP")
|
||||
```
|
||||
|
||||
## OpenAPI response documentation
|
||||
|
||||
Use [`generate_error_responses`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.generate_error_responses) to add error schemas to your endpoint's OpenAPI spec:
|
||||
@@ -78,8 +124,7 @@ from fastapi_toolsets.exceptions import generate_error_responses, NotFoundError,
|
||||
async def get_user(...): ...
|
||||
```
|
||||
|
||||
!!! info
|
||||
The pydantic validation error is automatically added by FastAPI.
|
||||
Multiple exceptions sharing the same HTTP status code are grouped under one entry, each appearing as a named example keyed by its `err_code`. This keeps the OpenAPI UI readable when several error variants map to the same status.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
# Models
|
||||
|
||||
!!! info "Added in `v2.0`"
|
||||
|
||||
Reusable SQLAlchemy 2.0 mixins for common column patterns, designed to be composed freely on any `DeclarativeBase` model.
|
||||
|
||||
## Overview
|
||||
|
||||
The `models` module provides mixins that each add a single, well-defined column behaviour. They work with standard SQLAlchemy 2.0 declarative syntax and are fully compatible with `AsyncSession`.
|
||||
|
||||
```python
|
||||
from fastapi_toolsets.models import UUIDMixin, TimestampMixin
|
||||
|
||||
class Article(Base, UUIDMixin, TimestampMixin):
|
||||
__tablename__ = "articles"
|
||||
|
||||
title: Mapped[str]
|
||||
content: Mapped[str]
|
||||
```
|
||||
|
||||
All timestamp columns are timezone-aware (`TIMESTAMPTZ`). All defaults are server-side, so they are also applied when inserting rows via raw SQL outside the ORM.
|
||||
|
||||
## Mixins
|
||||
|
||||
### [`UUIDMixin`](../reference/models.md#fastapi_toolsets.models.UUIDMixin)
|
||||
|
||||
Adds a `id: UUID` primary key generated server-side by PostgreSQL using `gen_random_uuid()` (requires PostgreSQL 13+). The value is retrieved via `RETURNING` after insert, so it is available on the Python object immediately after `flush()`.
|
||||
|
||||
```python
|
||||
from fastapi_toolsets.models import UUIDMixin
|
||||
|
||||
class User(Base, UUIDMixin):
|
||||
__tablename__ = "users"
|
||||
|
||||
username: Mapped[str]
|
||||
|
||||
# id is None before flush
|
||||
user = User(username="alice")
|
||||
await session.flush()
|
||||
print(user.id) # UUID('...')
|
||||
```
|
||||
|
||||
### [`CreatedAtMixin`](../reference/models.md#fastapi_toolsets.models.CreatedAtMixin)
|
||||
|
||||
Adds a `created_at: datetime` column set to `NOW()` on insert. The column has no `onupdate` hook — it is intentionally immutable after the row is created.
|
||||
|
||||
```python
|
||||
from fastapi_toolsets.models import UUIDMixin, CreatedAtMixin
|
||||
|
||||
class Order(Base, UUIDMixin, CreatedAtMixin):
|
||||
__tablename__ = "orders"
|
||||
|
||||
total: Mapped[float]
|
||||
```
|
||||
|
||||
### [`UpdatedAtMixin`](../reference/models.md#fastapi_toolsets.models.UpdatedAtMixin)
|
||||
|
||||
Adds an `updated_at: datetime` column set to `NOW()` on insert and automatically updated to `NOW()` on every ORM-level update (via SQLAlchemy's `onupdate` hook).
|
||||
|
||||
```python
|
||||
from fastapi_toolsets.models import UUIDMixin, UpdatedAtMixin
|
||||
|
||||
class Post(Base, UUIDMixin, UpdatedAtMixin):
|
||||
__tablename__ = "posts"
|
||||
|
||||
title: Mapped[str]
|
||||
|
||||
post = Post(title="Hello")
|
||||
await session.flush()
|
||||
await session.refresh(post)
|
||||
|
||||
post.title = "Hello World"
|
||||
await session.flush()
|
||||
await session.refresh(post)
|
||||
print(post.updated_at)
|
||||
```
|
||||
|
||||
!!! note
|
||||
`updated_at` is updated by SQLAlchemy at ORM flush time. If you update rows via raw SQL (e.g. `UPDATE posts SET ...`), the column will **not** be updated automatically — use a database trigger if you need that guarantee.
|
||||
|
||||
### [`TimestampMixin`](../reference/models.md#fastapi_toolsets.models.TimestampMixin)
|
||||
|
||||
Convenience mixin that combines [`CreatedAtMixin`](../reference/models.md#fastapi_toolsets.models.CreatedAtMixin) and [`UpdatedAtMixin`](../reference/models.md#fastapi_toolsets.models.UpdatedAtMixin). Equivalent to inheriting both.
|
||||
|
||||
```python
|
||||
from fastapi_toolsets.models import UUIDMixin, TimestampMixin
|
||||
|
||||
class Article(Base, UUIDMixin, TimestampMixin):
|
||||
__tablename__ = "articles"
|
||||
|
||||
title: Mapped[str]
|
||||
```
|
||||
|
||||
## Composing mixins
|
||||
|
||||
All mixins can be combined in any order. The only constraint is that exactly one primary key must be defined — either via `UUIDMixin` or directly on the model.
|
||||
|
||||
```python
|
||||
from fastapi_toolsets.models import UUIDMixin, TimestampMixin
|
||||
|
||||
class Event(Base, UUIDMixin, TimestampMixin):
|
||||
__tablename__ = "events"
|
||||
name: Mapped[str]
|
||||
|
||||
class Counter(Base, UpdatedAtMixin):
|
||||
__tablename__ = "counters"
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
value: Mapped[int]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
[:material-api: API Reference](../reference/models.md)
|
||||
@@ -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)
|
||||
@@ -13,6 +13,7 @@ from fastapi_toolsets.exceptions import (
|
||||
ConflictError,
|
||||
NoSearchableFieldsError,
|
||||
InvalidFacetFilterError,
|
||||
InvalidOrderFieldError,
|
||||
generate_error_responses,
|
||||
init_exceptions_handlers,
|
||||
)
|
||||
@@ -32,6 +33,8 @@ from fastapi_toolsets.exceptions import (
|
||||
|
||||
## ::: fastapi_toolsets.exceptions.exceptions.InvalidFacetFilterError
|
||||
|
||||
## ::: fastapi_toolsets.exceptions.exceptions.InvalidOrderFieldError
|
||||
|
||||
## ::: fastapi_toolsets.exceptions.exceptions.generate_error_responses
|
||||
|
||||
## ::: fastapi_toolsets.exceptions.handler.init_exceptions_handlers
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# `models`
|
||||
|
||||
Here's the reference for the SQLAlchemy model mixins provided by the `models` module.
|
||||
|
||||
You can import them directly from `fastapi_toolsets.models`:
|
||||
|
||||
```python
|
||||
from fastapi_toolsets.models import (
|
||||
UUIDMixin,
|
||||
CreatedAtMixin,
|
||||
UpdatedAtMixin,
|
||||
TimestampMixin,
|
||||
)
|
||||
```
|
||||
|
||||
## ::: fastapi_toolsets.models.UUIDMixin
|
||||
|
||||
## ::: fastapi_toolsets.models.CreatedAtMixin
|
||||
|
||||
## ::: fastapi_toolsets.models.UpdatedAtMixin
|
||||
|
||||
## ::: fastapi_toolsets.models.TimestampMixin
|
||||
@@ -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
|
||||
@@ -0,0 +1,9 @@
|
||||
from fastapi import FastAPI
|
||||
|
||||
from fastapi_toolsets.exceptions import init_exceptions_handlers
|
||||
|
||||
from .routes import router
|
||||
|
||||
app = FastAPI()
|
||||
init_exceptions_handlers(app=app)
|
||||
app.include_router(router=router)
|
||||
@@ -0,0 +1,21 @@
|
||||
from fastapi_toolsets.crud import CrudFactory
|
||||
|
||||
from .models import Article, Category
|
||||
|
||||
ArticleCrud = CrudFactory(
|
||||
model=Article,
|
||||
cursor_column=Article.created_at,
|
||||
searchable_fields=[ # default fields for full-text search
|
||||
Article.title,
|
||||
Article.body,
|
||||
(Article.category, Category.name),
|
||||
],
|
||||
facet_fields=[ # fields exposed as filter dropdowns
|
||||
Article.status,
|
||||
(Article.category, Category.name),
|
||||
],
|
||||
order_fields=[ # fields exposed for client-driven ordering
|
||||
Article.title,
|
||||
Article.created_at,
|
||||
],
|
||||
)
|
||||
@@ -0,0 +1,17 @@
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
from fastapi_toolsets.db import create_db_context, create_db_dependency
|
||||
|
||||
DATABASE_URL = "postgresql+asyncpg://postgres:postgres@localhost:5432/postgres"
|
||||
|
||||
engine = create_async_engine(url=DATABASE_URL, future=True)
|
||||
async_session_maker = async_sessionmaker(bind=engine, expire_on_commit=False)
|
||||
|
||||
get_db = create_db_dependency(session_maker=async_session_maker)
|
||||
get_db_context = create_db_context(session_maker=async_session_maker)
|
||||
|
||||
|
||||
SessionDep = Annotated[AsyncSession, Depends(get_db)]
|
||||
@@ -0,0 +1,36 @@
|
||||
import datetime
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text, func
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
class Category(Base):
|
||||
__tablename__ = "categories"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||
name: Mapped[str] = mapped_column(String(64), unique=True)
|
||||
|
||||
articles: Mapped[list["Article"]] = relationship(back_populates="category")
|
||||
|
||||
|
||||
class Article(Base):
|
||||
__tablename__ = "articles"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||
created_at: Mapped[datetime.datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
title: Mapped[str] = mapped_column(String(256))
|
||||
body: Mapped[str] = mapped_column(Text)
|
||||
status: Mapped[str] = mapped_column(String(32))
|
||||
published: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
category_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
ForeignKey("categories.id"), nullable=True
|
||||
)
|
||||
|
||||
category: Mapped["Category | None"] = relationship(back_populates="articles")
|
||||
@@ -0,0 +1,59 @@
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
|
||||
from fastapi_toolsets.crud import OrderByClause
|
||||
from fastapi_toolsets.schemas import PaginatedResponse
|
||||
|
||||
from .crud import ArticleCrud
|
||||
from .db import SessionDep
|
||||
from .models import Article
|
||||
from .schemas import ArticleRead
|
||||
|
||||
router = APIRouter(prefix="/articles")
|
||||
|
||||
|
||||
@router.get("/offset")
|
||||
async def list_articles_offset(
|
||||
session: SessionDep,
|
||||
filter_by: Annotated[dict[str, list[str]], Depends(ArticleCrud.filter_params())],
|
||||
order_by: Annotated[
|
||||
OrderByClause | None,
|
||||
Depends(ArticleCrud.order_params(default_field=Article.created_at)),
|
||||
],
|
||||
page: int = Query(1, ge=1),
|
||||
items_per_page: int = Query(20, ge=1, le=100),
|
||||
search: str | None = None,
|
||||
) -> PaginatedResponse[ArticleRead]:
|
||||
return await ArticleCrud.offset_paginate(
|
||||
session=session,
|
||||
page=page,
|
||||
items_per_page=items_per_page,
|
||||
search=search,
|
||||
filter_by=filter_by or None,
|
||||
order_by=order_by,
|
||||
schema=ArticleRead,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/cursor")
|
||||
async def list_articles_cursor(
|
||||
session: SessionDep,
|
||||
filter_by: Annotated[dict[str, list[str]], Depends(ArticleCrud.filter_params())],
|
||||
order_by: Annotated[
|
||||
OrderByClause | None,
|
||||
Depends(ArticleCrud.order_params(default_field=Article.created_at)),
|
||||
],
|
||||
cursor: str | None = None,
|
||||
items_per_page: int = Query(20, ge=1, le=100),
|
||||
search: str | None = None,
|
||||
) -> PaginatedResponse[ArticleRead]:
|
||||
return await ArticleCrud.cursor_paginate(
|
||||
session=session,
|
||||
cursor=cursor,
|
||||
items_per_page=items_per_page,
|
||||
search=search,
|
||||
filter_by=filter_by or None,
|
||||
order_by=order_by,
|
||||
schema=ArticleRead,
|
||||
)
|
||||
@@ -0,0 +1,13 @@
|
||||
import datetime
|
||||
import uuid
|
||||
|
||||
from fastapi_toolsets.schemas import PydanticBase
|
||||
|
||||
|
||||
class ArticleRead(PydanticBase):
|
||||
id: uuid.UUID
|
||||
created_at: datetime.datetime
|
||||
title: str
|
||||
status: str
|
||||
published: bool
|
||||
category_id: uuid.UUID | None
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "fastapi-toolsets"
|
||||
version = "1.2.0"
|
||||
description = "Reusable tools for FastAPI: async CRUD, fixtures, CLI, and standardized responses for SQLAlchemy + PostgreSQL"
|
||||
version = "1.3.0"
|
||||
description = "Production-ready utilities for FastAPI applications"
|
||||
readme = "README.md"
|
||||
license = "MIT"
|
||||
license-files = ["LICENSE"]
|
||||
@@ -11,7 +11,7 @@ authors = [
|
||||
]
|
||||
keywords = ["fastapi", "sqlalchemy", "postgresql"]
|
||||
classifiers = [
|
||||
"Development Status :: 4 - Beta",
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
"Framework :: AsyncIO",
|
||||
"Framework :: FastAPI",
|
||||
"Framework :: Pydantic",
|
||||
|
||||
@@ -21,4 +21,4 @@ Example usage:
|
||||
return Response(data={"user": user.username}, message="Success")
|
||||
"""
|
||||
|
||||
__version__ = "1.2.0"
|
||||
__version__ = "1.3.0"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Generic async CRUD operations for SQLAlchemy models."""
|
||||
|
||||
from ..exceptions import InvalidFacetFilterError, NoSearchableFieldsError
|
||||
from .factory import CrudFactory, JoinType, M2MFieldType
|
||||
from .factory import CrudFactory, JoinType, M2MFieldType, OrderByClause
|
||||
from .search import (
|
||||
FacetFieldType,
|
||||
SearchConfig,
|
||||
@@ -16,5 +16,6 @@ __all__ = [
|
||||
"JoinType",
|
||||
"M2MFieldType",
|
||||
"NoSearchableFieldsError",
|
||||
"OrderByClause",
|
||||
"SearchConfig",
|
||||
]
|
||||
|
||||
@@ -21,10 +21,11 @@ from sqlalchemy.exc import NoResultFound
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import DeclarativeBase, QueryableAttribute, selectinload
|
||||
from sqlalchemy.sql.base import ExecutableOption
|
||||
from sqlalchemy.sql.elements import ColumnElement
|
||||
from sqlalchemy.sql.roles import WhereHavingRole
|
||||
|
||||
from ..db import get_transaction
|
||||
from ..exceptions import NotFoundError
|
||||
from ..exceptions import InvalidOrderFieldError, NotFoundError
|
||||
from ..schemas import CursorPagination, OffsetPagination, PaginatedResponse, Response
|
||||
from .search import (
|
||||
FacetFieldType,
|
||||
@@ -40,6 +41,7 @@ ModelType = TypeVar("ModelType", bound=DeclarativeBase)
|
||||
SchemaType = TypeVar("SchemaType", bound=BaseModel)
|
||||
JoinType = list[tuple[type[DeclarativeBase], Any]]
|
||||
M2MFieldType = Mapping[str, QueryableAttribute[Any]]
|
||||
OrderByClause = ColumnElement[Any] | QueryableAttribute[Any]
|
||||
|
||||
|
||||
def _encode_cursor(value: Any) -> str:
|
||||
@@ -61,6 +63,7 @@ class AsyncCrud(Generic[ModelType]):
|
||||
model: ClassVar[type[DeclarativeBase]]
|
||||
searchable_fields: ClassVar[Sequence[SearchFieldType] | None] = None
|
||||
facet_fields: ClassVar[Sequence[FacetFieldType] | None] = None
|
||||
order_fields: ClassVar[Sequence[QueryableAttribute[Any]] | None] = None
|
||||
m2m_fields: ClassVar[M2MFieldType | None] = None
|
||||
default_load_options: ClassVar[list[ExecutableOption] | None] = None
|
||||
cursor_column: ClassVar[Any | None] = None
|
||||
@@ -176,6 +179,63 @@ class AsyncCrud(Generic[ModelType]):
|
||||
|
||||
return dependency
|
||||
|
||||
@classmethod
|
||||
def order_params(
|
||||
cls: type[Self],
|
||||
*,
|
||||
order_fields: Sequence[QueryableAttribute[Any]] | None = None,
|
||||
default_field: QueryableAttribute[Any] | None = None,
|
||||
default_order: Literal["asc", "desc"] = "asc",
|
||||
) -> Callable[..., Awaitable[OrderByClause | None]]:
|
||||
"""Return a FastAPI dependency that resolves order query params into an order_by clause.
|
||||
|
||||
Args:
|
||||
order_fields: Override the allowed order fields. Falls back to the class-level
|
||||
``order_fields`` if not provided.
|
||||
default_field: Field to order by when ``order_by`` query param is absent.
|
||||
If ``None`` and no ``order_by`` is provided, no ordering is applied.
|
||||
default_order: Default order direction when ``order`` is absent
|
||||
(``"asc"`` or ``"desc"``).
|
||||
|
||||
Returns:
|
||||
An async dependency function named ``{Model}OrderParams`` that resolves to an
|
||||
``OrderByClause`` (or ``None``). Pass it to ``Depends()`` in your route.
|
||||
|
||||
Raises:
|
||||
ValueError: If no order fields are configured on this CRUD class and none are
|
||||
provided via ``order_fields``.
|
||||
InvalidOrderFieldError: When the request provides an unknown ``order_by`` value.
|
||||
"""
|
||||
fields = order_fields if order_fields is not None else cls.order_fields
|
||||
if not fields:
|
||||
raise ValueError(
|
||||
f"{cls.__name__} has no order_fields configured. "
|
||||
"Pass order_fields= or set them on CrudFactory."
|
||||
)
|
||||
field_map: dict[str, QueryableAttribute[Any]] = {f.key: f for f in fields}
|
||||
valid_keys = sorted(field_map.keys())
|
||||
|
||||
async def dependency(
|
||||
order_by: str | None = Query(
|
||||
None, description=f"Field to order by. Valid values: {valid_keys}"
|
||||
),
|
||||
order: Literal["asc", "desc"] = Query(
|
||||
default_order, description="Sort direction"
|
||||
),
|
||||
) -> OrderByClause | None:
|
||||
if order_by is None:
|
||||
if default_field is None:
|
||||
return None
|
||||
field = default_field
|
||||
elif order_by not in field_map:
|
||||
raise InvalidOrderFieldError(order_by, valid_keys)
|
||||
else:
|
||||
field = field_map[order_by]
|
||||
return field.asc() if order == "asc" else field.desc()
|
||||
|
||||
dependency.__name__ = f"{cls.model.__name__}OrderParams"
|
||||
return dependency
|
||||
|
||||
@overload
|
||||
@classmethod
|
||||
async def create( # pragma: no cover
|
||||
@@ -415,7 +475,7 @@ class AsyncCrud(Generic[ModelType]):
|
||||
joins: JoinType | None = None,
|
||||
outer_join: bool = False,
|
||||
load_options: list[ExecutableOption] | None = None,
|
||||
order_by: Any | None = None,
|
||||
order_by: OrderByClause | None = None,
|
||||
limit: int | None = None,
|
||||
offset: int | None = None,
|
||||
) -> Sequence[ModelType]:
|
||||
@@ -745,7 +805,7 @@ class AsyncCrud(Generic[ModelType]):
|
||||
joins: JoinType | None = None,
|
||||
outer_join: bool = False,
|
||||
load_options: list[ExecutableOption] | None = None,
|
||||
order_by: Any | None = None,
|
||||
order_by: OrderByClause | None = None,
|
||||
page: int = 1,
|
||||
items_per_page: int = 20,
|
||||
search: str | SearchConfig | None = None,
|
||||
@@ -766,7 +826,7 @@ class AsyncCrud(Generic[ModelType]):
|
||||
joins: JoinType | None = None,
|
||||
outer_join: bool = False,
|
||||
load_options: list[ExecutableOption] | None = None,
|
||||
order_by: Any | None = None,
|
||||
order_by: OrderByClause | None = None,
|
||||
page: int = 1,
|
||||
items_per_page: int = 20,
|
||||
search: str | SearchConfig | None = None,
|
||||
@@ -785,7 +845,7 @@ class AsyncCrud(Generic[ModelType]):
|
||||
joins: JoinType | None = None,
|
||||
outer_join: bool = False,
|
||||
load_options: list[ExecutableOption] | None = None,
|
||||
order_by: Any | None = None,
|
||||
order_by: OrderByClause | None = None,
|
||||
page: int = 1,
|
||||
items_per_page: int = 20,
|
||||
search: str | SearchConfig | None = None,
|
||||
@@ -937,7 +997,7 @@ class AsyncCrud(Generic[ModelType]):
|
||||
joins: JoinType | None = None,
|
||||
outer_join: bool = False,
|
||||
load_options: list[ExecutableOption] | None = None,
|
||||
order_by: Any | None = None,
|
||||
order_by: OrderByClause | None = None,
|
||||
items_per_page: int = 20,
|
||||
search: str | SearchConfig | None = None,
|
||||
search_fields: Sequence[SearchFieldType] | None = None,
|
||||
@@ -958,7 +1018,7 @@ class AsyncCrud(Generic[ModelType]):
|
||||
joins: JoinType | None = None,
|
||||
outer_join: bool = False,
|
||||
load_options: list[ExecutableOption] | None = None,
|
||||
order_by: Any | None = None,
|
||||
order_by: OrderByClause | None = None,
|
||||
items_per_page: int = 20,
|
||||
search: str | SearchConfig | None = None,
|
||||
search_fields: Sequence[SearchFieldType] | None = None,
|
||||
@@ -977,7 +1037,7 @@ class AsyncCrud(Generic[ModelType]):
|
||||
joins: JoinType | None = None,
|
||||
outer_join: bool = False,
|
||||
load_options: list[ExecutableOption] | None = None,
|
||||
order_by: Any | None = None,
|
||||
order_by: OrderByClause | None = None,
|
||||
items_per_page: int = 20,
|
||||
search: str | SearchConfig | None = None,
|
||||
search_fields: Sequence[SearchFieldType] | None = None,
|
||||
@@ -1147,6 +1207,7 @@ def CrudFactory(
|
||||
*,
|
||||
searchable_fields: Sequence[SearchFieldType] | None = None,
|
||||
facet_fields: Sequence[FacetFieldType] | None = None,
|
||||
order_fields: Sequence[QueryableAttribute[Any]] | None = None,
|
||||
m2m_fields: M2MFieldType | None = None,
|
||||
default_load_options: list[ExecutableOption] | None = None,
|
||||
cursor_column: Any | None = None,
|
||||
@@ -1159,6 +1220,8 @@ def CrudFactory(
|
||||
facet_fields: Optional list of columns to compute distinct values for in paginated
|
||||
responses. Supports direct columns (``User.status``) and relationship tuples
|
||||
(``(User.role, Role.name)``). Can be overridden per call.
|
||||
order_fields: Optional list of model attributes that callers are allowed to order by
|
||||
via ``order_params()``. Can be overridden per call.
|
||||
m2m_fields: Optional mapping for many-to-many relationships.
|
||||
Maps schema field names (containing lists of IDs) to
|
||||
SQLAlchemy relationship attributes.
|
||||
@@ -1252,6 +1315,7 @@ def CrudFactory(
|
||||
"model": model,
|
||||
"searchable_fields": searchable_fields,
|
||||
"facet_fields": facet_fields,
|
||||
"order_fields": order_fields,
|
||||
"m2m_fields": m2m_fields,
|
||||
"default_load_options": default_load_options,
|
||||
"cursor_column": cursor_column,
|
||||
|
||||
@@ -10,6 +10,8 @@ from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
from .exceptions import NotFoundError
|
||||
|
||||
__all__ = [
|
||||
"LockMode",
|
||||
"create_db_context",
|
||||
@@ -216,7 +218,7 @@ async def wait_for_row_change(
|
||||
The refreshed model instance with updated values
|
||||
|
||||
Raises:
|
||||
LookupError: If the row does not exist or is deleted during polling
|
||||
NotFoundError: If the row does not exist or is deleted during polling
|
||||
TimeoutError: If timeout expires before a change is detected
|
||||
|
||||
Example:
|
||||
@@ -237,7 +239,7 @@ async def wait_for_row_change(
|
||||
"""
|
||||
instance = await session.get(model, pk_value)
|
||||
if instance is None:
|
||||
raise LookupError(f"{model.__name__} with pk={pk_value!r} not found")
|
||||
raise NotFoundError(f"{model.__name__} with pk={pk_value!r} not found")
|
||||
|
||||
if columns is not None:
|
||||
watch_cols = columns
|
||||
@@ -261,7 +263,7 @@ async def wait_for_row_change(
|
||||
instance = await session.get(model, pk_value)
|
||||
|
||||
if instance is None:
|
||||
raise LookupError(f"{model.__name__} with pk={pk_value!r} was deleted")
|
||||
raise NotFoundError(f"{model.__name__} with pk={pk_value!r} was deleted")
|
||||
|
||||
current = {col: getattr(instance, col) for col in watch_cols}
|
||||
if current != initial:
|
||||
|
||||
@@ -6,6 +6,7 @@ from .exceptions import (
|
||||
ConflictError,
|
||||
ForbiddenError,
|
||||
InvalidFacetFilterError,
|
||||
InvalidOrderFieldError,
|
||||
NoSearchableFieldsError,
|
||||
NotFoundError,
|
||||
UnauthorizedError,
|
||||
@@ -21,6 +22,7 @@ __all__ = [
|
||||
"generate_error_responses",
|
||||
"init_exceptions_handlers",
|
||||
"InvalidFacetFilterError",
|
||||
"InvalidOrderFieldError",
|
||||
"NoSearchableFieldsError",
|
||||
"NotFoundError",
|
||||
"UnauthorizedError",
|
||||
|
||||
@@ -6,32 +6,46 @@ from ..schemas import ApiError, ErrorResponse, ResponseStatus
|
||||
|
||||
|
||||
class ApiException(Exception):
|
||||
"""Base exception for API errors with structured response.
|
||||
|
||||
Subclass this to create custom API exceptions with consistent error format.
|
||||
The exception handler will use api_error to generate the response.
|
||||
|
||||
Example:
|
||||
```python
|
||||
class CustomError(ApiException):
|
||||
api_error = ApiError(
|
||||
code=400,
|
||||
msg="Bad Request",
|
||||
desc="The request was invalid.",
|
||||
err_code="CUSTOM-400",
|
||||
)
|
||||
```
|
||||
"""
|
||||
"""Base exception for API errors with structured response."""
|
||||
|
||||
api_error: ClassVar[ApiError]
|
||||
|
||||
def __init__(self, detail: str | None = None):
|
||||
def __init_subclass__(cls, abstract: bool = False, **kwargs: Any) -> None:
|
||||
super().__init_subclass__(**kwargs)
|
||||
if not abstract and not hasattr(cls, "api_error"):
|
||||
raise TypeError(
|
||||
f"{cls.__name__} must define an 'api_error' class attribute. "
|
||||
"Pass abstract=True when creating intermediate base classes."
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
detail: str | None = None,
|
||||
*,
|
||||
desc: str | None = None,
|
||||
data: Any = None,
|
||||
) -> None:
|
||||
"""Initialize the exception.
|
||||
|
||||
Args:
|
||||
detail: Optional override for the error message
|
||||
detail: Optional human-readable message
|
||||
desc: Optional per-instance override for the ``description`` field
|
||||
in the HTTP response body.
|
||||
data: Optional per-instance override for the ``data`` field in the
|
||||
HTTP response body.
|
||||
"""
|
||||
super().__init__(detail or self.api_error.msg)
|
||||
updates: dict[str, Any] = {}
|
||||
if detail is not None:
|
||||
updates["msg"] = detail
|
||||
if desc is not None:
|
||||
updates["desc"] = desc
|
||||
if data is not None:
|
||||
updates["data"] = data
|
||||
if updates:
|
||||
object.__setattr__(
|
||||
self, "api_error", self.__class__.api_error.model_copy(update=updates)
|
||||
)
|
||||
super().__init__(self.api_error.msg)
|
||||
|
||||
|
||||
class UnauthorizedError(ApiException):
|
||||
@@ -92,14 +106,15 @@ class NoSearchableFieldsError(ApiException):
|
||||
"""Initialize the exception.
|
||||
|
||||
Args:
|
||||
model: The SQLAlchemy model class that has no searchable fields
|
||||
model: The model class that has no searchable fields configured.
|
||||
"""
|
||||
self.model = model
|
||||
detail = (
|
||||
f"No searchable fields found for model '{model.__name__}'. "
|
||||
"Provide 'search_fields' parameter or set 'searchable_fields' on the CRUD class."
|
||||
super().__init__(
|
||||
desc=(
|
||||
f"No searchable fields found for model '{model.__name__}'. "
|
||||
"Provide 'search_fields' parameter or set 'searchable_fields' on the CRUD class."
|
||||
)
|
||||
)
|
||||
super().__init__(detail)
|
||||
|
||||
|
||||
class InvalidFacetFilterError(ApiException):
|
||||
@@ -116,16 +131,41 @@ class InvalidFacetFilterError(ApiException):
|
||||
"""Initialize the exception.
|
||||
|
||||
Args:
|
||||
key: The unknown filter key provided by the caller
|
||||
valid_keys: Set of valid keys derived from the declared facet_fields
|
||||
key: The unknown filter key provided by the caller.
|
||||
valid_keys: Set of valid keys derived from the declared facet_fields.
|
||||
"""
|
||||
self.key = key
|
||||
self.valid_keys = valid_keys
|
||||
detail = (
|
||||
f"'{key}' is not a declared facet field. "
|
||||
f"Valid keys: {sorted(valid_keys) or 'none — set facet_fields on the CRUD class'}."
|
||||
super().__init__(
|
||||
desc=(
|
||||
f"'{key}' is not a declared facet field. "
|
||||
f"Valid keys: {sorted(valid_keys) or 'none — set facet_fields on the CRUD class'}."
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class InvalidOrderFieldError(ApiException):
|
||||
"""Raised when order_by contains a field not in the allowed order fields."""
|
||||
|
||||
api_error = ApiError(
|
||||
code=422,
|
||||
msg="Invalid Order Field",
|
||||
desc="The requested order field is not allowed for this resource.",
|
||||
err_code="SORT-422",
|
||||
)
|
||||
|
||||
def __init__(self, field: str, valid_fields: list[str]) -> None:
|
||||
"""Initialize the exception.
|
||||
|
||||
Args:
|
||||
field: The unknown order field provided by the caller.
|
||||
valid_fields: List of valid field names.
|
||||
"""
|
||||
self.field = field
|
||||
self.valid_fields = valid_fields
|
||||
super().__init__(
|
||||
desc=f"'{field}' is not an allowed order field. Valid fields: {valid_fields}."
|
||||
)
|
||||
super().__init__(detail)
|
||||
|
||||
|
||||
def generate_error_responses(
|
||||
@@ -133,44 +173,39 @@ def generate_error_responses(
|
||||
) -> dict[int | str, dict[str, Any]]:
|
||||
"""Generate OpenAPI response documentation for exceptions.
|
||||
|
||||
Use this to document possible error responses for an endpoint.
|
||||
|
||||
Args:
|
||||
*errors: Exception classes that inherit from ApiException
|
||||
*errors: Exception classes that inherit from ApiException.
|
||||
|
||||
Returns:
|
||||
Dict suitable for FastAPI's responses parameter
|
||||
|
||||
Example:
|
||||
```python
|
||||
from fastapi_toolsets.exceptions import generate_error_responses, UnauthorizedError, ForbiddenError
|
||||
|
||||
@app.get(
|
||||
"/admin",
|
||||
responses=generate_error_responses(UnauthorizedError, ForbiddenError)
|
||||
)
|
||||
async def admin_endpoint():
|
||||
...
|
||||
```
|
||||
Dict suitable for FastAPI's ``responses`` parameter.
|
||||
"""
|
||||
responses: dict[int | str, dict[str, Any]] = {}
|
||||
|
||||
for error in errors:
|
||||
api_error = error.api_error
|
||||
code = api_error.code
|
||||
|
||||
responses[api_error.code] = {
|
||||
"model": ErrorResponse,
|
||||
"description": api_error.msg,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {
|
||||
"data": api_error.data,
|
||||
"status": ResponseStatus.FAIL.value,
|
||||
"message": api_error.msg,
|
||||
"description": api_error.desc,
|
||||
"error_code": api_error.err_code,
|
||||
if code not in responses:
|
||||
responses[code] = {
|
||||
"model": ErrorResponse,
|
||||
"description": api_error.msg,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"examples": {},
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
responses[code]["content"]["application/json"]["examples"][
|
||||
api_error.err_code
|
||||
] = {
|
||||
"summary": api_error.msg,
|
||||
"value": {
|
||||
"data": api_error.data,
|
||||
"status": ResponseStatus.FAIL.value,
|
||||
"message": api_error.msg,
|
||||
"description": api_error.desc,
|
||||
"error_code": api_error.err_code,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
"""Exception handlers for FastAPI applications."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from fastapi import FastAPI, Request, Response, status
|
||||
from fastapi.exceptions import RequestValidationError, ResponseValidationError
|
||||
from fastapi.openapi.utils import get_openapi
|
||||
from fastapi.exceptions import (
|
||||
HTTPException,
|
||||
RequestValidationError,
|
||||
ResponseValidationError,
|
||||
)
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from ..schemas import ErrorResponse, ResponseStatus
|
||||
@@ -14,43 +18,20 @@ from .exceptions import ApiException
|
||||
def init_exceptions_handlers(app: FastAPI) -> FastAPI:
|
||||
"""Register exception handlers and custom OpenAPI schema on a FastAPI app.
|
||||
|
||||
Installs handlers for :class:`ApiException`, validation errors, and
|
||||
unhandled exceptions, and replaces the default 422 schema with a
|
||||
consistent error format.
|
||||
|
||||
Args:
|
||||
app: FastAPI application instance
|
||||
app: FastAPI application instance.
|
||||
|
||||
Returns:
|
||||
The same FastAPI instance (for chaining)
|
||||
|
||||
Example:
|
||||
```python
|
||||
from fastapi import FastAPI
|
||||
from fastapi_toolsets.exceptions import init_exceptions_handlers
|
||||
|
||||
app = FastAPI()
|
||||
init_exceptions_handlers(app)
|
||||
```
|
||||
The same FastAPI instance (for chaining).
|
||||
"""
|
||||
_register_exception_handlers(app)
|
||||
app.openapi = lambda: _custom_openapi(app) # type: ignore[method-assign]
|
||||
_original_openapi = app.openapi
|
||||
app.openapi = lambda: _patched_openapi(app, _original_openapi) # type: ignore[method-assign]
|
||||
return app
|
||||
|
||||
|
||||
def _register_exception_handlers(app: FastAPI) -> None:
|
||||
"""Register all exception handlers on a FastAPI application.
|
||||
|
||||
Args:
|
||||
app: FastAPI application instance
|
||||
|
||||
Example:
|
||||
from fastapi import FastAPI
|
||||
from fastapi_toolsets.exceptions import init_exceptions_handlers
|
||||
|
||||
app = FastAPI()
|
||||
init_exceptions_handlers(app)
|
||||
"""
|
||||
"""Register all exception handlers on a FastAPI application."""
|
||||
|
||||
@app.exception_handler(ApiException)
|
||||
async def api_exception_handler(request: Request, exc: ApiException) -> Response:
|
||||
@@ -62,12 +43,25 @@ def _register_exception_handlers(app: FastAPI) -> None:
|
||||
description=api_error.desc,
|
||||
error_code=api_error.err_code,
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=api_error.code,
|
||||
content=error_response.model_dump(),
|
||||
)
|
||||
|
||||
@app.exception_handler(HTTPException)
|
||||
async def http_exception_handler(request: Request, exc: HTTPException) -> Response:
|
||||
"""Handle Starlette/FastAPI HTTPException with a consistent error format."""
|
||||
detail = exc.detail if isinstance(exc.detail, str) else "HTTP Error"
|
||||
error_response = ErrorResponse(
|
||||
message=detail,
|
||||
error_code=f"HTTP-{exc.status_code}",
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content=error_response.model_dump(),
|
||||
headers=getattr(exc, "headers", None),
|
||||
)
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def request_validation_handler(
|
||||
request: Request, exc: RequestValidationError
|
||||
@@ -90,7 +84,6 @@ def _register_exception_handlers(app: FastAPI) -> None:
|
||||
description="An unexpected error occurred. Please try again later.",
|
||||
error_code="SERVER-500",
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
content=error_response.model_dump(),
|
||||
@@ -105,11 +98,10 @@ def _format_validation_error(
|
||||
formatted_errors = []
|
||||
|
||||
for error in errors:
|
||||
field_path = ".".join(
|
||||
str(loc)
|
||||
for loc in error["loc"]
|
||||
if loc not in ("body", "query", "path", "header", "cookie")
|
||||
)
|
||||
locs = error["loc"]
|
||||
if locs and locs[0] in ("body", "query", "path", "header", "cookie"):
|
||||
locs = locs[1:]
|
||||
field_path = ".".join(str(loc) for loc in locs)
|
||||
formatted_errors.append(
|
||||
{
|
||||
"field": field_path or "root",
|
||||
@@ -131,34 +123,22 @@ def _format_validation_error(
|
||||
)
|
||||
|
||||
|
||||
def _custom_openapi(app: FastAPI) -> dict[str, Any]:
|
||||
"""Generate custom OpenAPI schema with standardized error format.
|
||||
|
||||
Replaces default 422 validation error responses with the custom format.
|
||||
def _patched_openapi(
|
||||
app: FastAPI, original_openapi: Callable[[], dict[str, Any]]
|
||||
) -> dict[str, Any]:
|
||||
"""Generate the OpenAPI schema and replace default 422 responses.
|
||||
|
||||
Args:
|
||||
app: FastAPI application instance
|
||||
app: FastAPI application instance.
|
||||
original_openapi: The previous ``app.openapi`` callable to delegate to.
|
||||
|
||||
Returns:
|
||||
OpenAPI schema dict
|
||||
|
||||
Example:
|
||||
from fastapi import FastAPI
|
||||
from fastapi_toolsets.exceptions import init_exceptions_handlers
|
||||
|
||||
app = FastAPI()
|
||||
init_exceptions_handlers(app) # Automatically sets custom OpenAPI
|
||||
Patched OpenAPI schema dict.
|
||||
"""
|
||||
if app.openapi_schema:
|
||||
return app.openapi_schema
|
||||
|
||||
openapi_schema = get_openapi(
|
||||
title=app.title,
|
||||
version=app.version,
|
||||
openapi_version=app.openapi_version,
|
||||
description=app.description,
|
||||
routes=app.routes,
|
||||
)
|
||||
openapi_schema = original_openapi()
|
||||
|
||||
for path_data in openapi_schema.get("paths", {}).values():
|
||||
for operation in path_data.values():
|
||||
@@ -168,20 +148,25 @@ def _custom_openapi(app: FastAPI) -> dict[str, Any]:
|
||||
"description": "Validation Error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {
|
||||
"data": {
|
||||
"errors": [
|
||||
{
|
||||
"field": "field_name",
|
||||
"message": "value is not valid",
|
||||
"type": "value_error",
|
||||
}
|
||||
]
|
||||
},
|
||||
"status": ResponseStatus.FAIL.value,
|
||||
"message": "Validation Error",
|
||||
"description": "1 validation error(s) detected",
|
||||
"error_code": "VAL-422",
|
||||
"examples": {
|
||||
"VAL-422": {
|
||||
"summary": "Validation Error",
|
||||
"value": {
|
||||
"data": {
|
||||
"errors": [
|
||||
{
|
||||
"field": "field_name",
|
||||
"message": "value is not valid",
|
||||
"type": "value_error",
|
||||
}
|
||||
]
|
||||
},
|
||||
"status": ResponseStatus.FAIL.value,
|
||||
"message": "Validation Error",
|
||||
"description": "1 validation error(s) detected",
|
||||
"error_code": "VAL-422",
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
"""SQLAlchemy model mixins for common column patterns."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, Uuid, func, text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
__all__ = [
|
||||
"UUIDMixin",
|
||||
"CreatedAtMixin",
|
||||
"UpdatedAtMixin",
|
||||
"TimestampMixin",
|
||||
]
|
||||
|
||||
|
||||
class UUIDMixin:
|
||||
"""Mixin that adds a UUID primary key auto-generated by the database."""
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
Uuid,
|
||||
primary_key=True,
|
||||
server_default=text("gen_random_uuid()"),
|
||||
)
|
||||
|
||||
|
||||
class CreatedAtMixin:
|
||||
"""Mixin that adds a ``created_at`` timestamp column."""
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
)
|
||||
|
||||
|
||||
class UpdatedAtMixin:
|
||||
"""Mixin that adds an ``updated_at`` timestamp column."""
|
||||
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
)
|
||||
|
||||
|
||||
class TimestampMixin(CreatedAtMixin, UpdatedAtMixin):
|
||||
"""Mixin that combines ``created_at`` and ``updated_at`` timestamp columns."""
|
||||
@@ -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},
|
||||
)
|
||||
+146
-2
@@ -1,9 +1,11 @@
|
||||
"""Tests for CRUD search functionality."""
|
||||
|
||||
import inspect
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.sql.elements import ColumnElement, UnaryExpression
|
||||
|
||||
from fastapi_toolsets.crud import (
|
||||
CrudFactory,
|
||||
@@ -11,6 +13,7 @@ from fastapi_toolsets.crud import (
|
||||
SearchConfig,
|
||||
get_searchable_fields,
|
||||
)
|
||||
from fastapi_toolsets.exceptions import InvalidOrderFieldError
|
||||
from fastapi_toolsets.schemas import OffsetPagination
|
||||
|
||||
from .conftest import (
|
||||
@@ -407,7 +410,7 @@ class TestNoSearchableFieldsError:
|
||||
from fastapi_toolsets.exceptions import NoSearchableFieldsError
|
||||
|
||||
error = NoSearchableFieldsError(User)
|
||||
assert "User" in str(error)
|
||||
assert "User" in error.api_error.desc
|
||||
assert error.model is User
|
||||
|
||||
def test_error_raised_when_no_fields(self):
|
||||
@@ -431,7 +434,7 @@ class TestNoSearchableFieldsError:
|
||||
build_search_filters(NoStringModel, "test")
|
||||
|
||||
assert exc_info.value.model is NoStringModel
|
||||
assert "NoStringModel" in str(exc_info.value)
|
||||
assert "NoStringModel" in exc_info.value.api_error.desc
|
||||
|
||||
|
||||
class TestGetSearchableFields:
|
||||
@@ -1014,3 +1017,144 @@ class TestFilterParamsSchema:
|
||||
|
||||
assert isinstance(result.pagination, OffsetPagination)
|
||||
assert result.pagination.total_count == 2
|
||||
|
||||
|
||||
class TestOrderParamsSchema:
|
||||
"""Tests for AsyncCrud.order_params()."""
|
||||
|
||||
def test_generates_order_by_and_order_params(self):
|
||||
"""Returned dependency has order_by and order query params."""
|
||||
UserOrderCrud = CrudFactory(User, order_fields=[User.username, User.email])
|
||||
dep = UserOrderCrud.order_params()
|
||||
|
||||
param_names = set(inspect.signature(dep).parameters)
|
||||
assert param_names == {"order_by", "order"}
|
||||
|
||||
def test_dependency_name_includes_model_name(self):
|
||||
"""Dependency function is named after the model."""
|
||||
UserOrderCrud = CrudFactory(User, order_fields=[User.username])
|
||||
dep = UserOrderCrud.order_params()
|
||||
assert getattr(dep, "__name__") == "UserOrderParams"
|
||||
|
||||
def test_raises_when_no_order_fields(self):
|
||||
"""ValueError raised when no order_fields are configured or provided."""
|
||||
with pytest.raises(ValueError, match="no order_fields"):
|
||||
UserCrud.order_params()
|
||||
|
||||
def test_order_fields_override(self):
|
||||
"""order_fields= parameter overrides the class-level default."""
|
||||
UserOrderCrud = CrudFactory(User, order_fields=[User.username, User.email])
|
||||
dep = UserOrderCrud.order_params(order_fields=[User.email])
|
||||
|
||||
param_names = set(inspect.signature(dep).parameters)
|
||||
assert "order_by" in param_names
|
||||
# description should only mention email, not username
|
||||
sig = inspect.signature(dep)
|
||||
description = sig.parameters["order_by"].default.description
|
||||
assert "email" in description
|
||||
assert "username" not in description
|
||||
|
||||
def test_order_by_description_lists_valid_fields(self):
|
||||
"""order_by query param description mentions each allowed field."""
|
||||
UserOrderCrud = CrudFactory(User, order_fields=[User.username, User.email])
|
||||
dep = UserOrderCrud.order_params()
|
||||
|
||||
sig = inspect.signature(dep)
|
||||
description = sig.parameters["order_by"].default.description
|
||||
assert "username" in description
|
||||
assert "email" in description
|
||||
|
||||
def test_default_order_reflected_in_order_default(self):
|
||||
"""default_order is used as the default value for order."""
|
||||
UserOrderCrud = CrudFactory(User, order_fields=[User.username])
|
||||
dep_asc = UserOrderCrud.order_params(default_order="asc")
|
||||
dep_desc = UserOrderCrud.order_params(default_order="desc")
|
||||
|
||||
sig_asc = inspect.signature(dep_asc)
|
||||
sig_desc = inspect.signature(dep_desc)
|
||||
assert sig_asc.parameters["order"].default.default == "asc"
|
||||
assert sig_desc.parameters["order"].default.default == "desc"
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_no_order_by_no_default_returns_none(self):
|
||||
"""Returns None when order_by is absent and no default_field is set."""
|
||||
UserOrderCrud = CrudFactory(User, order_fields=[User.username])
|
||||
dep = UserOrderCrud.order_params()
|
||||
result = await dep(order_by=None, order="asc")
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_no_order_by_with_default_field_returns_asc_expression(self):
|
||||
"""Returns default_field.asc() when order_by absent and order=asc."""
|
||||
UserOrderCrud = CrudFactory(User, order_fields=[User.username])
|
||||
dep = UserOrderCrud.order_params(default_field=User.username)
|
||||
result = await dep(order_by=None, order="asc")
|
||||
assert isinstance(result, UnaryExpression)
|
||||
assert "ASC" in str(result)
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_no_order_by_with_default_field_returns_desc_expression(self):
|
||||
"""Returns default_field.desc() when order_by absent and order=desc."""
|
||||
UserOrderCrud = CrudFactory(User, order_fields=[User.username])
|
||||
dep = UserOrderCrud.order_params(default_field=User.username)
|
||||
result = await dep(order_by=None, order="desc")
|
||||
assert isinstance(result, UnaryExpression)
|
||||
assert "DESC" in str(result)
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_valid_order_by_asc(self):
|
||||
"""Returns field.asc() for a valid order_by with order=asc."""
|
||||
UserOrderCrud = CrudFactory(User, order_fields=[User.username])
|
||||
dep = UserOrderCrud.order_params()
|
||||
result = await dep(order_by="username", order="asc")
|
||||
assert isinstance(result, UnaryExpression)
|
||||
assert "ASC" in str(result)
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_valid_order_by_desc(self):
|
||||
"""Returns field.desc() for a valid order_by with order=desc."""
|
||||
UserOrderCrud = CrudFactory(User, order_fields=[User.username])
|
||||
dep = UserOrderCrud.order_params()
|
||||
result = await dep(order_by="username", order="desc")
|
||||
assert isinstance(result, UnaryExpression)
|
||||
assert "DESC" in str(result)
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_invalid_order_by_raises_invalid_order_field_error(self):
|
||||
"""Raises InvalidOrderFieldError for an unknown order_by value."""
|
||||
UserOrderCrud = CrudFactory(User, order_fields=[User.username])
|
||||
dep = UserOrderCrud.order_params()
|
||||
with pytest.raises(InvalidOrderFieldError) as exc_info:
|
||||
await dep(order_by="nonexistent", order="asc")
|
||||
assert exc_info.value.field == "nonexistent"
|
||||
assert "username" in exc_info.value.valid_fields
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_multiple_fields_all_resolve(self):
|
||||
"""All configured fields resolve correctly via order_by."""
|
||||
UserOrderCrud = CrudFactory(User, order_fields=[User.username, User.email])
|
||||
dep = UserOrderCrud.order_params()
|
||||
result_username = await dep(order_by="username", order="asc")
|
||||
result_email = await dep(order_by="email", order="desc")
|
||||
assert isinstance(result_username, ColumnElement)
|
||||
assert isinstance(result_email, ColumnElement)
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_order_params_integrates_with_get_multi(
|
||||
self, db_session: AsyncSession
|
||||
):
|
||||
"""order_params output is accepted by get_multi(order_by=...)."""
|
||||
UserOrderCrud = CrudFactory(User, order_fields=[User.username])
|
||||
await UserCrud.create(
|
||||
db_session, UserCreate(username="charlie", email="c@test.com")
|
||||
)
|
||||
await UserCrud.create(
|
||||
db_session, UserCreate(username="alice", email="a@test.com")
|
||||
)
|
||||
|
||||
dep = UserOrderCrud.order_params()
|
||||
order_by = await dep(order_by="username", order="asc")
|
||||
results = await UserOrderCrud.get_multi(db_session, order_by=order_by)
|
||||
|
||||
assert results[0].username == "alice"
|
||||
assert results[1].username == "charlie"
|
||||
|
||||
+5
-4
@@ -14,6 +14,7 @@ from fastapi_toolsets.db import (
|
||||
lock_tables,
|
||||
wait_for_row_change,
|
||||
)
|
||||
from fastapi_toolsets.exceptions import NotFoundError
|
||||
|
||||
from .conftest import DATABASE_URL, Base, Role, RoleCrud, User
|
||||
|
||||
@@ -307,9 +308,9 @@ class TestWaitForRowChange:
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_nonexistent_row_raises(self, db_session: AsyncSession):
|
||||
"""Raises LookupError when the row does not exist."""
|
||||
"""Raises NotFoundError when the row does not exist."""
|
||||
fake_id = uuid.uuid4()
|
||||
with pytest.raises(LookupError, match="not found"):
|
||||
with pytest.raises(NotFoundError, match="not found"):
|
||||
await wait_for_row_change(db_session, Role, fake_id, interval=0.05)
|
||||
|
||||
@pytest.mark.anyio
|
||||
@@ -326,7 +327,7 @@ class TestWaitForRowChange:
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_deleted_row_raises(self, db_session: AsyncSession, engine):
|
||||
"""Raises LookupError when the row is deleted during polling."""
|
||||
"""Raises NotFoundError when the row is deleted during polling."""
|
||||
role = Role(name="delete_role")
|
||||
db_session.add(role)
|
||||
await db_session.commit()
|
||||
@@ -340,6 +341,6 @@ class TestWaitForRowChange:
|
||||
await other.commit()
|
||||
|
||||
delete_task = asyncio.create_task(delete_later())
|
||||
with pytest.raises(LookupError):
|
||||
with pytest.raises(NotFoundError):
|
||||
await wait_for_row_change(db_session, Role, role.id, interval=0.05)
|
||||
await delete_task
|
||||
|
||||
@@ -0,0 +1,395 @@
|
||||
"""Live test for the docs/examples/pagination-search.md example.
|
||||
|
||||
Spins up the exact FastAPI app described in the example (sourced from
|
||||
docs_src/examples/pagination_search/) and exercises it through a real HTTP
|
||||
client against a real PostgreSQL database.
|
||||
"""
|
||||
|
||||
import datetime
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
from docs_src.examples.pagination_search.db import get_db
|
||||
from docs_src.examples.pagination_search.models import Article, Base, Category
|
||||
from docs_src.examples.pagination_search.routes import router
|
||||
from fastapi_toolsets.exceptions import init_exceptions_handlers
|
||||
|
||||
from .conftest import DATABASE_URL
|
||||
|
||||
|
||||
def build_app(session: AsyncSession) -> FastAPI:
|
||||
app = FastAPI()
|
||||
init_exceptions_handlers(app)
|
||||
|
||||
async def override_get_db():
|
||||
yield session
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
app.include_router(router)
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
async def ex_db_session():
|
||||
"""Isolated session for the example models (separate tables from conftest)."""
|
||||
engine = create_async_engine(DATABASE_URL, echo=False)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
session_factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||
session = session_factory()
|
||||
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
await session.close()
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.drop_all)
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client(ex_db_session: AsyncSession):
|
||||
app = build_app(ex_db_session)
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=app), base_url="http://test"
|
||||
) as ac:
|
||||
yield ac
|
||||
|
||||
|
||||
async def seed(session: AsyncSession):
|
||||
"""Insert representative fixture data."""
|
||||
python = Category(name="python")
|
||||
backend = Category(name="backend")
|
||||
session.add_all([python, backend])
|
||||
await session.flush()
|
||||
|
||||
now = datetime.datetime(2024, 1, 1, tzinfo=datetime.timezone.utc)
|
||||
session.add_all(
|
||||
[
|
||||
Article(
|
||||
title="FastAPI tips",
|
||||
body="Ten useful tips for FastAPI.",
|
||||
status="published",
|
||||
published=True,
|
||||
category_id=python.id,
|
||||
created_at=now,
|
||||
),
|
||||
Article(
|
||||
title="SQLAlchemy async",
|
||||
body="How to use async SQLAlchemy.",
|
||||
status="published",
|
||||
published=True,
|
||||
category_id=backend.id,
|
||||
created_at=now + datetime.timedelta(seconds=1),
|
||||
),
|
||||
Article(
|
||||
title="Draft notes",
|
||||
body="Work in progress.",
|
||||
status="draft",
|
||||
published=False,
|
||||
category_id=None,
|
||||
created_at=now + datetime.timedelta(seconds=2),
|
||||
),
|
||||
]
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
|
||||
class TestAppSessionDep:
|
||||
@pytest.mark.anyio
|
||||
async def test_get_db_yields_async_session(self):
|
||||
"""get_db yields a real AsyncSession when called directly."""
|
||||
from docs_src.examples.pagination_search.db import get_db
|
||||
|
||||
gen = get_db()
|
||||
session = await gen.__anext__()
|
||||
assert isinstance(session, AsyncSession)
|
||||
await session.close()
|
||||
|
||||
|
||||
class TestOffsetPagination:
|
||||
@pytest.mark.anyio
|
||||
async def test_returns_all_articles(self, client: AsyncClient, ex_db_session):
|
||||
await seed(ex_db_session)
|
||||
|
||||
resp = await client.get("/articles/offset")
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["pagination"]["total_count"] == 3
|
||||
assert len(body["data"]) == 3
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_pagination_page_size(self, client: AsyncClient, ex_db_session):
|
||||
await seed(ex_db_session)
|
||||
|
||||
resp = await client.get("/articles/offset?items_per_page=2&page=1")
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert len(body["data"]) == 2
|
||||
assert body["pagination"]["total_count"] == 3
|
||||
assert body["pagination"]["has_more"] is True
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_full_text_search(self, client: AsyncClient, ex_db_session):
|
||||
await seed(ex_db_session)
|
||||
|
||||
resp = await client.get("/articles/offset?search=fastapi")
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["pagination"]["total_count"] == 1
|
||||
assert body["data"][0]["title"] == "FastAPI tips"
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_search_traverses_relationship(
|
||||
self, client: AsyncClient, ex_db_session
|
||||
):
|
||||
await seed(ex_db_session)
|
||||
|
||||
# "python" matches Category.name, not Article.title or body
|
||||
resp = await client.get("/articles/offset?search=python")
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["pagination"]["total_count"] == 1
|
||||
assert body["data"][0]["title"] == "FastAPI tips"
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_facet_filter_scalar(self, client: AsyncClient, ex_db_session):
|
||||
await seed(ex_db_session)
|
||||
|
||||
resp = await client.get("/articles/offset?status=published")
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["pagination"]["total_count"] == 2
|
||||
assert all(a["status"] == "published" for a in body["data"])
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_facet_filter_multi_value(self, client: AsyncClient, ex_db_session):
|
||||
await seed(ex_db_session)
|
||||
|
||||
resp = await client.get("/articles/offset?status=published&status=draft")
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["pagination"]["total_count"] == 3
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_filter_attributes_in_response(
|
||||
self, client: AsyncClient, ex_db_session
|
||||
):
|
||||
await seed(ex_db_session)
|
||||
|
||||
resp = await client.get("/articles/offset")
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
fa = body["filter_attributes"]
|
||||
assert set(fa["status"]) == {"draft", "published"}
|
||||
# "name" is unique across all facet fields — no prefix needed
|
||||
assert set(fa["name"]) == {"backend", "python"}
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_filter_attributes_scoped_to_filter(
|
||||
self, client: AsyncClient, ex_db_session
|
||||
):
|
||||
await seed(ex_db_session)
|
||||
|
||||
resp = await client.get("/articles/offset?status=published")
|
||||
|
||||
body = resp.json()
|
||||
# draft is filtered out → should not appear in filter_attributes
|
||||
assert "draft" not in body["filter_attributes"]["status"]
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_search_and_filter_combined(self, client: AsyncClient, ex_db_session):
|
||||
await seed(ex_db_session)
|
||||
|
||||
resp = await client.get("/articles/offset?search=async&status=published")
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["pagination"]["total_count"] == 1
|
||||
assert body["data"][0]["title"] == "SQLAlchemy async"
|
||||
|
||||
|
||||
class TestCursorPagination:
|
||||
@pytest.mark.anyio
|
||||
async def test_first_page(self, client: AsyncClient, ex_db_session):
|
||||
await seed(ex_db_session)
|
||||
|
||||
resp = await client.get("/articles/cursor?items_per_page=2")
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert len(body["data"]) == 2
|
||||
assert body["pagination"]["has_more"] is True
|
||||
assert body["pagination"]["next_cursor"] is not None
|
||||
assert body["pagination"]["prev_cursor"] is None
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_second_page(self, client: AsyncClient, ex_db_session):
|
||||
await seed(ex_db_session)
|
||||
|
||||
first = await client.get("/articles/cursor?items_per_page=2")
|
||||
next_cursor = first.json()["pagination"]["next_cursor"]
|
||||
|
||||
resp = await client.get(
|
||||
f"/articles/cursor?items_per_page=2&cursor={next_cursor}"
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert len(body["data"]) == 1
|
||||
assert body["pagination"]["has_more"] is False
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_facet_filter(self, client: AsyncClient, ex_db_session):
|
||||
await seed(ex_db_session)
|
||||
|
||||
resp = await client.get("/articles/cursor?status=draft")
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert len(body["data"]) == 1
|
||||
assert body["data"][0]["status"] == "draft"
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_full_text_search(self, client: AsyncClient, ex_db_session):
|
||||
await seed(ex_db_session)
|
||||
|
||||
resp = await client.get("/articles/cursor?search=sqlalchemy")
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert len(body["data"]) == 1
|
||||
assert body["data"][0]["title"] == "SQLAlchemy async"
|
||||
|
||||
|
||||
class TestOffsetSorting:
|
||||
"""Tests for order_by / order query parameters on the offset endpoint."""
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_default_order_uses_created_at_asc(
|
||||
self, client: AsyncClient, ex_db_session
|
||||
):
|
||||
"""No order_by → default field (created_at) ASC."""
|
||||
await seed(ex_db_session)
|
||||
|
||||
resp = await client.get("/articles/offset")
|
||||
|
||||
assert resp.status_code == 200
|
||||
titles = [a["title"] for a in resp.json()["data"]]
|
||||
assert titles == ["FastAPI tips", "SQLAlchemy async", "Draft notes"]
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_order_by_title_asc(self, client: AsyncClient, ex_db_session):
|
||||
"""order_by=title&order=asc returns alphabetical order."""
|
||||
await seed(ex_db_session)
|
||||
|
||||
resp = await client.get("/articles/offset?order_by=title&order=asc")
|
||||
|
||||
assert resp.status_code == 200
|
||||
titles = [a["title"] for a in resp.json()["data"]]
|
||||
assert titles == ["Draft notes", "FastAPI tips", "SQLAlchemy async"]
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_order_by_title_desc(self, client: AsyncClient, ex_db_session):
|
||||
"""order_by=title&order=desc returns reverse alphabetical order."""
|
||||
await seed(ex_db_session)
|
||||
|
||||
resp = await client.get("/articles/offset?order_by=title&order=desc")
|
||||
|
||||
assert resp.status_code == 200
|
||||
titles = [a["title"] for a in resp.json()["data"]]
|
||||
assert titles == ["SQLAlchemy async", "FastAPI tips", "Draft notes"]
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_order_by_created_at_desc(self, client: AsyncClient, ex_db_session):
|
||||
"""order_by=created_at&order=desc returns newest-first."""
|
||||
await seed(ex_db_session)
|
||||
|
||||
resp = await client.get("/articles/offset?order_by=created_at&order=desc")
|
||||
|
||||
assert resp.status_code == 200
|
||||
titles = [a["title"] for a in resp.json()["data"]]
|
||||
assert titles == ["Draft notes", "SQLAlchemy async", "FastAPI tips"]
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_invalid_order_by_returns_422(
|
||||
self, client: AsyncClient, ex_db_session
|
||||
):
|
||||
"""Unknown order_by field returns 422 with SORT-422 error code."""
|
||||
resp = await client.get("/articles/offset?order_by=nonexistent_field")
|
||||
|
||||
assert resp.status_code == 422
|
||||
body = resp.json()
|
||||
assert body["error_code"] == "SORT-422"
|
||||
assert body["status"] == "FAIL"
|
||||
|
||||
|
||||
class TestCursorSorting:
|
||||
"""Tests for order_by / order query parameters on the cursor endpoint.
|
||||
|
||||
In cursor_paginate the cursor_column is always the primary sort; order_by
|
||||
acts as a secondary tiebreaker. With the seeded articles (all having unique
|
||||
created_at values) the overall ordering is always created_at ASC regardless
|
||||
of the order_by value — only the valid/invalid field check and the response
|
||||
shape are meaningful here.
|
||||
"""
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_default_order_uses_created_at_asc(
|
||||
self, client: AsyncClient, ex_db_session
|
||||
):
|
||||
"""No order_by → default field (created_at) ASC."""
|
||||
await seed(ex_db_session)
|
||||
|
||||
resp = await client.get("/articles/cursor")
|
||||
|
||||
assert resp.status_code == 200
|
||||
titles = [a["title"] for a in resp.json()["data"]]
|
||||
assert titles == ["FastAPI tips", "SQLAlchemy async", "Draft notes"]
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_order_by_title_asc_accepted(
|
||||
self, client: AsyncClient, ex_db_session
|
||||
):
|
||||
"""order_by=title is a valid field — request succeeds and returns all articles."""
|
||||
await seed(ex_db_session)
|
||||
|
||||
resp = await client.get("/articles/cursor?order_by=title&order=asc")
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert len(resp.json()["data"]) == 3
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_order_by_title_desc_accepted(
|
||||
self, client: AsyncClient, ex_db_session
|
||||
):
|
||||
"""order_by=title&order=desc is valid — request succeeds and returns all articles."""
|
||||
await seed(ex_db_session)
|
||||
|
||||
resp = await client.get("/articles/cursor?order_by=title&order=desc")
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert len(resp.json()["data"]) == 3
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_invalid_order_by_returns_422(
|
||||
self, client: AsyncClient, ex_db_session
|
||||
):
|
||||
"""Unknown order_by field returns 422 with SORT-422 error code."""
|
||||
resp = await client.get("/articles/cursor?order_by=nonexistent_field")
|
||||
|
||||
assert resp.status_code == 422
|
||||
body = resp.json()
|
||||
assert body["error_code"] == "SORT-422"
|
||||
assert body["status"] == "FAIL"
|
||||
+448
-18
@@ -2,12 +2,14 @@
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.exceptions import HTTPException
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from fastapi_toolsets.exceptions import (
|
||||
ApiException,
|
||||
ConflictError,
|
||||
ForbiddenError,
|
||||
InvalidOrderFieldError,
|
||||
NotFoundError,
|
||||
UnauthorizedError,
|
||||
generate_error_responses,
|
||||
@@ -35,8 +37,8 @@ class TestApiException:
|
||||
assert error.api_error.msg == "I'm a teapot"
|
||||
assert str(error) == "I'm a teapot"
|
||||
|
||||
def test_custom_detail_message(self):
|
||||
"""Custom detail overrides default message."""
|
||||
def test_detail_overrides_msg_and_str(self):
|
||||
"""detail sets both str(exc) and api_error.msg; class-level msg is unchanged."""
|
||||
|
||||
class CustomError(ApiException):
|
||||
api_error = ApiError(
|
||||
@@ -46,8 +48,172 @@ class TestApiException:
|
||||
err_code="BAD-400",
|
||||
)
|
||||
|
||||
error = CustomError("Custom message")
|
||||
assert str(error) == "Custom message"
|
||||
error = CustomError("Widget not found")
|
||||
assert str(error) == "Widget not found"
|
||||
assert error.api_error.msg == "Widget not found"
|
||||
assert CustomError.api_error.msg == "Bad Request" # class unchanged
|
||||
|
||||
def test_desc_override(self):
|
||||
"""desc kwarg overrides api_error.desc on the instance only."""
|
||||
|
||||
class MyError(ApiException):
|
||||
api_error = ApiError(
|
||||
code=400, msg="Error", desc="Default.", err_code="ERR-400"
|
||||
)
|
||||
|
||||
err = MyError(desc="Custom desc.")
|
||||
assert err.api_error.desc == "Custom desc."
|
||||
assert MyError.api_error.desc == "Default." # class unchanged
|
||||
|
||||
def test_data_override(self):
|
||||
"""data kwarg sets api_error.data on the instance only."""
|
||||
|
||||
class MyError(ApiException):
|
||||
api_error = ApiError(
|
||||
code=400, msg="Error", desc="Default.", err_code="ERR-400"
|
||||
)
|
||||
|
||||
err = MyError(data={"key": "value"})
|
||||
assert err.api_error.data == {"key": "value"}
|
||||
assert MyError.api_error.data is None # class unchanged
|
||||
|
||||
def test_desc_and_data_override(self):
|
||||
"""detail, desc and data can all be overridden together."""
|
||||
|
||||
class MyError(ApiException):
|
||||
api_error = ApiError(
|
||||
code=400, msg="Error", desc="Default.", err_code="ERR-400"
|
||||
)
|
||||
|
||||
err = MyError("custom msg", desc="New desc.", data={"x": 1})
|
||||
assert str(err) == "custom msg"
|
||||
assert err.api_error.msg == "custom msg" # detail also updates msg
|
||||
assert err.api_error.desc == "New desc."
|
||||
assert err.api_error.data == {"x": 1}
|
||||
assert err.api_error.code == 400 # other fields unchanged
|
||||
|
||||
def test_class_api_error_not_mutated_after_instance_override(self):
|
||||
"""Raising with desc/data does not mutate the class-level api_error."""
|
||||
|
||||
class MyError(ApiException):
|
||||
api_error = ApiError(
|
||||
code=400, msg="Error", desc="Default.", err_code="ERR-400"
|
||||
)
|
||||
|
||||
MyError(desc="Changed", data={"x": 1})
|
||||
assert MyError.api_error.desc == "Default."
|
||||
assert MyError.api_error.data is None
|
||||
|
||||
def test_subclass_uses_super_with_desc_and_data(self):
|
||||
"""Subclasses can delegate detail/desc/data to super().__init__()."""
|
||||
|
||||
class BuildValidationError(ApiException):
|
||||
api_error = ApiError(
|
||||
code=422,
|
||||
msg="Build Validation Error",
|
||||
desc="The build configuration is invalid.",
|
||||
err_code="BUILD-422",
|
||||
)
|
||||
|
||||
def __init__(self, *errors: str) -> None:
|
||||
super().__init__(
|
||||
f"{len(errors)} validation error(s)",
|
||||
desc=", ".join(errors),
|
||||
data={"errors": [{"message": e} for e in errors]},
|
||||
)
|
||||
|
||||
err = BuildValidationError("Field A is required", "Field B is invalid")
|
||||
assert str(err) == "2 validation error(s)"
|
||||
assert err.api_error.msg == "2 validation error(s)" # detail set msg
|
||||
assert err.api_error.desc == "Field A is required, Field B is invalid"
|
||||
assert err.api_error.data == {
|
||||
"errors": [
|
||||
{"message": "Field A is required"},
|
||||
{"message": "Field B is invalid"},
|
||||
]
|
||||
}
|
||||
assert err.api_error.code == 422 # other fields unchanged
|
||||
|
||||
def test_detail_desc_data_in_http_response(self):
|
||||
"""detail/desc/data overrides all appear correctly in the FastAPI HTTP response."""
|
||||
|
||||
class DynamicError(ApiException):
|
||||
api_error = ApiError(
|
||||
code=400, msg="Error", desc="Default.", err_code="ERR-400"
|
||||
)
|
||||
|
||||
def __init__(self, message: str) -> None:
|
||||
super().__init__(
|
||||
message,
|
||||
desc=f"Detail: {message}",
|
||||
data={"reason": message},
|
||||
)
|
||||
|
||||
app = FastAPI()
|
||||
init_exceptions_handlers(app)
|
||||
|
||||
@app.get("/error")
|
||||
async def raise_error():
|
||||
raise DynamicError("something went wrong")
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.get("/error")
|
||||
|
||||
assert response.status_code == 400
|
||||
body = response.json()
|
||||
assert body["message"] == "something went wrong"
|
||||
assert body["description"] == "Detail: something went wrong"
|
||||
assert body["data"] == {"reason": "something went wrong"}
|
||||
|
||||
|
||||
class TestApiExceptionGuard:
|
||||
"""Tests for the __init_subclass__ api_error guard."""
|
||||
|
||||
def test_missing_api_error_raises_type_error(self):
|
||||
"""Defining a subclass without api_error raises TypeError at class creation time."""
|
||||
with pytest.raises(
|
||||
TypeError, match="must define an 'api_error' class attribute"
|
||||
):
|
||||
|
||||
class BrokenError(ApiException):
|
||||
pass
|
||||
|
||||
def test_abstract_subclass_skips_guard(self):
|
||||
"""abstract=True allows intermediate base classes without api_error."""
|
||||
|
||||
class BaseGroupError(ApiException, abstract=True):
|
||||
pass
|
||||
|
||||
# Concrete child must still define it
|
||||
class ConcreteError(BaseGroupError):
|
||||
api_error = ApiError(
|
||||
code=400, msg="Error", desc="Desc.", err_code="ERR-400"
|
||||
)
|
||||
|
||||
err = ConcreteError()
|
||||
assert err.api_error.code == 400
|
||||
|
||||
def test_abstract_child_still_requires_api_error_on_concrete(self):
|
||||
"""Concrete subclass of an abstract class must define api_error."""
|
||||
|
||||
class Base(ApiException, abstract=True):
|
||||
pass
|
||||
|
||||
with pytest.raises(
|
||||
TypeError, match="must define an 'api_error' class attribute"
|
||||
):
|
||||
|
||||
class Concrete(Base):
|
||||
pass
|
||||
|
||||
def test_inherited_api_error_satisfies_guard(self):
|
||||
"""Subclass that inherits api_error from a parent does not need its own."""
|
||||
|
||||
class ConcreteError(NotFoundError):
|
||||
pass
|
||||
|
||||
err = ConcreteError()
|
||||
assert err.api_error.code == 404
|
||||
|
||||
|
||||
class TestBuiltInExceptions:
|
||||
@@ -89,7 +255,7 @@ class TestGenerateErrorResponses:
|
||||
assert responses[404]["description"] == "Not Found"
|
||||
|
||||
def test_generates_multiple_responses(self):
|
||||
"""Generates responses for multiple exceptions."""
|
||||
"""Generates responses for multiple exceptions with distinct status codes."""
|
||||
responses = generate_error_responses(
|
||||
UnauthorizedError,
|
||||
ForbiddenError,
|
||||
@@ -100,15 +266,24 @@ class TestGenerateErrorResponses:
|
||||
assert 403 in responses
|
||||
assert 404 in responses
|
||||
|
||||
def test_response_has_example(self):
|
||||
"""Generated response includes example."""
|
||||
def test_response_has_named_example(self):
|
||||
"""Generated response uses named examples keyed by err_code."""
|
||||
responses = generate_error_responses(NotFoundError)
|
||||
example = responses[404]["content"]["application/json"]["example"]
|
||||
examples = responses[404]["content"]["application/json"]["examples"]
|
||||
|
||||
assert example["status"] == "FAIL"
|
||||
assert example["error_code"] == "RES-404"
|
||||
assert example["message"] == "Not Found"
|
||||
assert example["data"] is None
|
||||
assert "RES-404" in examples
|
||||
value = examples["RES-404"]["value"]
|
||||
assert value["status"] == "FAIL"
|
||||
assert value["error_code"] == "RES-404"
|
||||
assert value["message"] == "Not Found"
|
||||
assert value["data"] is None
|
||||
|
||||
def test_response_example_has_summary(self):
|
||||
"""Each named example carries a summary equal to api_error.msg."""
|
||||
responses = generate_error_responses(NotFoundError)
|
||||
example = responses[404]["content"]["application/json"]["examples"]["RES-404"]
|
||||
|
||||
assert example["summary"] == "Not Found"
|
||||
|
||||
def test_response_example_with_data(self):
|
||||
"""Generated response includes data when set on ApiError."""
|
||||
@@ -123,9 +298,49 @@ class TestGenerateErrorResponses:
|
||||
)
|
||||
|
||||
responses = generate_error_responses(ErrorWithData)
|
||||
example = responses[400]["content"]["application/json"]["example"]
|
||||
value = responses[400]["content"]["application/json"]["examples"]["BAD-400"][
|
||||
"value"
|
||||
]
|
||||
|
||||
assert example["data"] == {"details": "some context"}
|
||||
assert value["data"] == {"details": "some context"}
|
||||
|
||||
def test_two_errors_same_code_both_present(self):
|
||||
"""Two exceptions with the same HTTP code produce two named examples."""
|
||||
|
||||
class BadRequestA(ApiException):
|
||||
api_error = ApiError(
|
||||
code=400, msg="Bad A", desc="Reason A.", err_code="ERR-A"
|
||||
)
|
||||
|
||||
class BadRequestB(ApiException):
|
||||
api_error = ApiError(
|
||||
code=400, msg="Bad B", desc="Reason B.", err_code="ERR-B"
|
||||
)
|
||||
|
||||
responses = generate_error_responses(BadRequestA, BadRequestB)
|
||||
|
||||
assert 400 in responses
|
||||
examples = responses[400]["content"]["application/json"]["examples"]
|
||||
assert "ERR-A" in examples
|
||||
assert "ERR-B" in examples
|
||||
assert examples["ERR-A"]["value"]["message"] == "Bad A"
|
||||
assert examples["ERR-B"]["value"]["message"] == "Bad B"
|
||||
|
||||
def test_two_errors_same_code_single_top_level_entry(self):
|
||||
"""Two exceptions with the same HTTP code produce exactly one top-level entry."""
|
||||
|
||||
class BadRequestA(ApiException):
|
||||
api_error = ApiError(
|
||||
code=400, msg="Bad A", desc="Reason A.", err_code="ERR-A"
|
||||
)
|
||||
|
||||
class BadRequestB(ApiException):
|
||||
api_error = ApiError(
|
||||
code=400, msg="Bad B", desc="Reason B.", err_code="ERR-B"
|
||||
)
|
||||
|
||||
responses = generate_error_responses(BadRequestA, BadRequestB)
|
||||
assert len([k for k in responses if k == 400]) == 1
|
||||
|
||||
|
||||
class TestInitExceptionsHandlers:
|
||||
@@ -249,13 +464,68 @@ class TestInitExceptionsHandlers:
|
||||
assert data["status"] == "FAIL"
|
||||
assert data["error_code"] == "SERVER-500"
|
||||
|
||||
def test_custom_openapi_schema(self):
|
||||
"""Customizes OpenAPI schema for 422 responses."""
|
||||
def test_handles_http_exception(self):
|
||||
"""Handles starlette HTTPException with consistent ErrorResponse envelope."""
|
||||
app = FastAPI()
|
||||
init_exceptions_handlers(app)
|
||||
|
||||
@app.get("/protected")
|
||||
async def protected():
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.get("/protected")
|
||||
|
||||
assert response.status_code == 403
|
||||
data = response.json()
|
||||
assert data["status"] == "FAIL"
|
||||
assert data["error_code"] == "HTTP-403"
|
||||
assert data["message"] == "Forbidden"
|
||||
|
||||
def test_handles_http_exception_404_from_route(self):
|
||||
"""HTTPException(404) raised inside a route uses the consistent ErrorResponse envelope."""
|
||||
app = FastAPI()
|
||||
init_exceptions_handlers(app)
|
||||
|
||||
@app.get("/items/{item_id}")
|
||||
async def get_item(item_id: int):
|
||||
raise HTTPException(status_code=404, detail="Item not found")
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.get("/items/99")
|
||||
|
||||
assert response.status_code == 404
|
||||
data = response.json()
|
||||
assert data["status"] == "FAIL"
|
||||
assert data["error_code"] == "HTTP-404"
|
||||
assert data["message"] == "Item not found"
|
||||
|
||||
def test_handles_http_exception_forwards_headers(self):
|
||||
"""HTTPException with WWW-Authenticate header forwards it in the response."""
|
||||
app = FastAPI()
|
||||
init_exceptions_handlers(app)
|
||||
|
||||
@app.get("/secure")
|
||||
async def secure():
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Not authenticated",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.get("/secure")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.headers.get("www-authenticate") == "Bearer"
|
||||
|
||||
def test_custom_openapi_schema(self):
|
||||
"""Customises OpenAPI schema for 422 responses using named examples."""
|
||||
from pydantic import BaseModel
|
||||
|
||||
app = FastAPI()
|
||||
init_exceptions_handlers(app)
|
||||
|
||||
class Item(BaseModel):
|
||||
name: str
|
||||
|
||||
@@ -268,8 +538,128 @@ class TestInitExceptionsHandlers:
|
||||
post_op = openapi["paths"]["/items"]["post"]
|
||||
assert "422" in post_op["responses"]
|
||||
resp_422 = post_op["responses"]["422"]
|
||||
example = resp_422["content"]["application/json"]["example"]
|
||||
assert example["error_code"] == "VAL-422"
|
||||
examples = resp_422["content"]["application/json"]["examples"]
|
||||
assert "VAL-422" in examples
|
||||
assert examples["VAL-422"]["value"]["error_code"] == "VAL-422"
|
||||
|
||||
def test_custom_openapi_preserves_app_metadata(self):
|
||||
"""_patched_openapi preserves custom FastAPI app-level metadata."""
|
||||
app = FastAPI(
|
||||
title="My API",
|
||||
version="2.0.0",
|
||||
description="Custom description",
|
||||
)
|
||||
init_exceptions_handlers(app)
|
||||
|
||||
schema = app.openapi()
|
||||
assert schema["info"]["title"] == "My API"
|
||||
assert schema["info"]["version"] == "2.0.0"
|
||||
|
||||
def test_handles_response_validation_error(self):
|
||||
"""Handles ResponseValidationError with a structured 422 response."""
|
||||
from pydantic import BaseModel
|
||||
|
||||
class CountResponse(BaseModel):
|
||||
count: int
|
||||
|
||||
app = FastAPI()
|
||||
init_exceptions_handlers(app)
|
||||
|
||||
@app.get("/broken", response_model=CountResponse)
|
||||
async def broken():
|
||||
return {"count": "not-a-number"} # triggers ResponseValidationError
|
||||
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
response = client.get("/broken")
|
||||
|
||||
assert response.status_code == 422
|
||||
data = response.json()
|
||||
assert data["status"] == "FAIL"
|
||||
assert data["error_code"] == "VAL-422"
|
||||
assert "errors" in data["data"]
|
||||
|
||||
def test_handles_validation_error_with_non_standard_loc(self):
|
||||
"""Validation error with empty loc tuple maps the field to 'root'."""
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
|
||||
app = FastAPI()
|
||||
init_exceptions_handlers(app)
|
||||
|
||||
@app.get("/root-error")
|
||||
async def root_error():
|
||||
raise RequestValidationError(
|
||||
[
|
||||
{
|
||||
"type": "custom",
|
||||
"loc": (),
|
||||
"msg": "root level error",
|
||||
"input": None,
|
||||
"url": "",
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.get("/root-error")
|
||||
|
||||
assert response.status_code == 422
|
||||
data = response.json()
|
||||
assert data["data"]["errors"][0]["field"] == "root"
|
||||
|
||||
def test_openapi_schema_cached_after_first_call(self):
|
||||
"""app.openapi() returns the cached schema on subsequent calls."""
|
||||
from pydantic import BaseModel
|
||||
|
||||
app = FastAPI()
|
||||
init_exceptions_handlers(app)
|
||||
|
||||
class Item(BaseModel):
|
||||
name: str
|
||||
|
||||
@app.post("/items")
|
||||
async def create_item(item: Item):
|
||||
return item
|
||||
|
||||
schema_first = app.openapi()
|
||||
schema_second = app.openapi()
|
||||
assert schema_first is schema_second
|
||||
|
||||
def test_openapi_skips_operations_without_422(self):
|
||||
"""_patched_openapi leaves operations that have no 422 response unchanged."""
|
||||
app = FastAPI()
|
||||
init_exceptions_handlers(app)
|
||||
|
||||
@app.get("/ping")
|
||||
async def ping():
|
||||
return {"ok": True}
|
||||
|
||||
schema = app.openapi()
|
||||
get_op = schema["paths"]["/ping"]["get"]
|
||||
assert "422" not in get_op["responses"]
|
||||
assert "200" in get_op["responses"]
|
||||
|
||||
def test_openapi_skips_non_dict_path_item_values(self):
|
||||
"""_patched_openapi ignores non-dict values in path items (e.g. path-level parameters)."""
|
||||
from fastapi_toolsets.exceptions.handler import _patched_openapi
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
def fake_openapi() -> dict:
|
||||
return {
|
||||
"paths": {
|
||||
"/items": {
|
||||
"parameters": [
|
||||
{"name": "q", "in": "query"}
|
||||
], # list, not a dict
|
||||
"get": {"responses": {"200": {"description": "OK"}}},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
schema = _patched_openapi(app, fake_openapi)
|
||||
# The list value was skipped without error; the GET operation is intact
|
||||
assert schema["paths"]["/items"]["parameters"] == [{"name": "q", "in": "query"}]
|
||||
assert "422" not in schema["paths"]["/items"]["get"]["responses"]
|
||||
|
||||
|
||||
class TestExceptionIntegration:
|
||||
@@ -334,3 +724,43 @@ class TestExceptionIntegration:
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"id": 1}
|
||||
|
||||
|
||||
class TestInvalidOrderFieldError:
|
||||
"""Tests for InvalidOrderFieldError exception."""
|
||||
|
||||
def test_api_error_attributes(self):
|
||||
"""InvalidOrderFieldError has correct api_error metadata."""
|
||||
assert InvalidOrderFieldError.api_error.code == 422
|
||||
assert InvalidOrderFieldError.api_error.err_code == "SORT-422"
|
||||
assert InvalidOrderFieldError.api_error.msg == "Invalid Order Field"
|
||||
|
||||
def test_stores_field_and_valid_fields(self):
|
||||
"""InvalidOrderFieldError stores field and valid_fields on the instance."""
|
||||
error = InvalidOrderFieldError("unknown", ["name", "created_at"])
|
||||
assert error.field == "unknown"
|
||||
assert error.valid_fields == ["name", "created_at"]
|
||||
|
||||
def test_description_contains_field_and_valid_fields(self):
|
||||
"""api_error.desc mentions the bad field and valid options."""
|
||||
error = InvalidOrderFieldError("bad_field", ["name", "email"])
|
||||
assert "bad_field" in error.api_error.desc
|
||||
assert "name" in error.api_error.desc
|
||||
assert "email" in error.api_error.desc
|
||||
|
||||
def test_handled_as_422_by_exception_handler(self):
|
||||
"""init_exceptions_handlers turns InvalidOrderFieldError into a 422 response."""
|
||||
app = FastAPI()
|
||||
init_exceptions_handlers(app)
|
||||
|
||||
@app.get("/items")
|
||||
async def list_items():
|
||||
raise InvalidOrderFieldError("bad", ["name"])
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.get("/items")
|
||||
|
||||
assert response.status_code == 422
|
||||
data = response.json()
|
||||
assert data["error_code"] == "SORT-422"
|
||||
assert data["status"] == "FAIL"
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
"""Tests for fastapi_toolsets.models mixins."""
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import String
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||
|
||||
from fastapi_toolsets.models import (
|
||||
CreatedAtMixin,
|
||||
TimestampMixin,
|
||||
UUIDMixin,
|
||||
UpdatedAtMixin,
|
||||
)
|
||||
|
||||
from .conftest import DATABASE_URL
|
||||
|
||||
|
||||
class MixinBase(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
class UUIDModel(MixinBase, UUIDMixin):
|
||||
__tablename__ = "mixin_uuid_models"
|
||||
|
||||
name: Mapped[str] = mapped_column(String(50))
|
||||
|
||||
|
||||
class UpdatedAtModel(MixinBase, UpdatedAtMixin):
|
||||
__tablename__ = "mixin_updated_at_models"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
name: Mapped[str] = mapped_column(String(50))
|
||||
|
||||
|
||||
class CreatedAtModel(MixinBase, CreatedAtMixin):
|
||||
__tablename__ = "mixin_created_at_models"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
name: Mapped[str] = mapped_column(String(50))
|
||||
|
||||
|
||||
class TimestampModel(MixinBase, TimestampMixin):
|
||||
__tablename__ = "mixin_timestamp_models"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
name: Mapped[str] = mapped_column(String(50))
|
||||
|
||||
|
||||
class FullMixinModel(MixinBase, UUIDMixin, UpdatedAtMixin):
|
||||
__tablename__ = "mixin_full_models"
|
||||
|
||||
name: Mapped[str] = mapped_column(String(50))
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
async def mixin_session():
|
||||
engine = create_async_engine(DATABASE_URL, echo=False)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(MixinBase.metadata.create_all)
|
||||
|
||||
session_factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||
session = session_factory()
|
||||
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
await session.close()
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(MixinBase.metadata.drop_all)
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
class TestUUIDMixin:
|
||||
@pytest.mark.anyio
|
||||
async def test_uuid_generated_by_db(self, mixin_session):
|
||||
"""UUID is generated server-side and populated after flush."""
|
||||
obj = UUIDModel(name="test")
|
||||
mixin_session.add(obj)
|
||||
await mixin_session.flush()
|
||||
|
||||
assert obj.id is not None
|
||||
assert isinstance(obj.id, uuid.UUID)
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_uuid_is_primary_key(self):
|
||||
"""UUIDMixin adds id as primary key column."""
|
||||
pk_cols = [c.name for c in UUIDModel.__table__.primary_key]
|
||||
assert pk_cols == ["id"]
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_each_row_gets_unique_uuid(self, mixin_session):
|
||||
"""Each inserted row gets a distinct UUID."""
|
||||
a = UUIDModel(name="a")
|
||||
b = UUIDModel(name="b")
|
||||
mixin_session.add_all([a, b])
|
||||
await mixin_session.flush()
|
||||
|
||||
assert a.id != b.id
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_uuid_server_default_set(self):
|
||||
"""Column has gen_random_uuid() as server default."""
|
||||
col = UUIDModel.__table__.c["id"]
|
||||
assert col.server_default is not None
|
||||
assert "gen_random_uuid" in str(col.server_default.arg)
|
||||
|
||||
|
||||
class TestUpdatedAtMixin:
|
||||
@pytest.mark.anyio
|
||||
async def test_updated_at_set_on_insert(self, mixin_session):
|
||||
"""updated_at is populated after insert."""
|
||||
obj = UpdatedAtModel(name="initial")
|
||||
mixin_session.add(obj)
|
||||
await mixin_session.flush()
|
||||
await mixin_session.refresh(obj)
|
||||
|
||||
assert obj.updated_at is not None
|
||||
assert obj.updated_at.tzinfo is not None # timezone-aware
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_updated_at_changes_on_update(self, mixin_session):
|
||||
"""updated_at is updated when the row is modified."""
|
||||
obj = UpdatedAtModel(name="initial")
|
||||
mixin_session.add(obj)
|
||||
await mixin_session.flush()
|
||||
await mixin_session.refresh(obj)
|
||||
|
||||
original_ts = obj.updated_at
|
||||
|
||||
obj.name = "modified"
|
||||
await mixin_session.flush()
|
||||
await mixin_session.refresh(obj)
|
||||
|
||||
assert obj.updated_at >= original_ts
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_updated_at_column_is_not_nullable(self):
|
||||
"""updated_at column is non-nullable."""
|
||||
col = UpdatedAtModel.__table__.c["updated_at"]
|
||||
assert not col.nullable
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_updated_at_has_server_default(self):
|
||||
"""updated_at column has a server-side default."""
|
||||
col = UpdatedAtModel.__table__.c["updated_at"]
|
||||
assert col.server_default is not None
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_updated_at_has_onupdate(self):
|
||||
"""updated_at column has an onupdate clause."""
|
||||
col = UpdatedAtModel.__table__.c["updated_at"]
|
||||
assert col.onupdate is not None
|
||||
|
||||
|
||||
class TestCreatedAtMixin:
|
||||
@pytest.mark.anyio
|
||||
async def test_created_at_set_on_insert(self, mixin_session):
|
||||
"""created_at is populated after insert."""
|
||||
obj = CreatedAtModel(name="new")
|
||||
mixin_session.add(obj)
|
||||
await mixin_session.flush()
|
||||
await mixin_session.refresh(obj)
|
||||
|
||||
assert obj.created_at is not None
|
||||
assert obj.created_at.tzinfo is not None # timezone-aware
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_created_at_not_changed_on_update(self, mixin_session):
|
||||
"""created_at is not modified when the row is updated."""
|
||||
obj = CreatedAtModel(name="original")
|
||||
mixin_session.add(obj)
|
||||
await mixin_session.flush()
|
||||
await mixin_session.refresh(obj)
|
||||
|
||||
original_ts = obj.created_at
|
||||
|
||||
obj.name = "updated"
|
||||
await mixin_session.flush()
|
||||
await mixin_session.refresh(obj)
|
||||
|
||||
assert obj.created_at == original_ts
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_created_at_column_is_not_nullable(self):
|
||||
"""created_at column is non-nullable."""
|
||||
col = CreatedAtModel.__table__.c["created_at"]
|
||||
assert not col.nullable
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_created_at_has_no_onupdate(self):
|
||||
"""created_at column has no onupdate clause."""
|
||||
col = CreatedAtModel.__table__.c["created_at"]
|
||||
assert col.onupdate is None
|
||||
|
||||
|
||||
class TestTimestampMixin:
|
||||
@pytest.mark.anyio
|
||||
async def test_both_columns_set_on_insert(self, mixin_session):
|
||||
"""created_at and updated_at are both populated after insert."""
|
||||
obj = TimestampModel(name="new")
|
||||
mixin_session.add(obj)
|
||||
await mixin_session.flush()
|
||||
await mixin_session.refresh(obj)
|
||||
|
||||
assert obj.created_at is not None
|
||||
assert obj.updated_at is not None
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_created_at_stable_updated_at_changes_on_update(self, mixin_session):
|
||||
"""On update: created_at stays the same, updated_at advances."""
|
||||
obj = TimestampModel(name="original")
|
||||
mixin_session.add(obj)
|
||||
await mixin_session.flush()
|
||||
await mixin_session.refresh(obj)
|
||||
|
||||
original_created = obj.created_at
|
||||
original_updated = obj.updated_at
|
||||
|
||||
obj.name = "modified"
|
||||
await mixin_session.flush()
|
||||
await mixin_session.refresh(obj)
|
||||
|
||||
assert obj.created_at == original_created
|
||||
assert obj.updated_at >= original_updated
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_timestamp_mixin_has_both_columns(self):
|
||||
"""TimestampModel exposes both created_at and updated_at columns."""
|
||||
col_names = {c.name for c in TimestampModel.__table__.columns}
|
||||
assert "created_at" in col_names
|
||||
assert "updated_at" in col_names
|
||||
|
||||
|
||||
class TestFullMixinModel:
|
||||
@pytest.mark.anyio
|
||||
async def test_combined_mixins_work_together(self, mixin_session):
|
||||
"""UUIDMixin and UpdatedAtMixin can be combined on the same model."""
|
||||
obj = FullMixinModel(name="combined")
|
||||
mixin_session.add(obj)
|
||||
await mixin_session.flush()
|
||||
await mixin_session.refresh(obj)
|
||||
|
||||
assert isinstance(obj.id, uuid.UUID)
|
||||
assert obj.updated_at is not None
|
||||
assert obj.updated_at.tzinfo is not None
|
||||
@@ -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]]
|
||||
name = "fastapi-toolsets"
|
||||
version = "1.2.0"
|
||||
version = "1.3.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "asyncpg" },
|
||||
|
||||
+40
-261
@@ -1,265 +1,35 @@
|
||||
# ============================================================================
|
||||
#
|
||||
# The configuration produced by default is meant to highlight the features
|
||||
# that Zensical provides and to serve as a starting point for your own
|
||||
# projects.
|
||||
#
|
||||
# ============================================================================
|
||||
|
||||
[project]
|
||||
|
||||
# The site_name is shown in the page header and the browser window title
|
||||
#
|
||||
# Read more: https://zensical.org/docs/setup/basics/#site_name
|
||||
site_name = "FastAPI Toolsets"
|
||||
|
||||
# The site_description is included in the HTML head and should contain a
|
||||
# meaningful description of the site content for use by search engines.
|
||||
#
|
||||
# Read more: https://zensical.org/docs/setup/basics/#site_description
|
||||
site_description = "Production-ready utilities for FastAPI applications."
|
||||
|
||||
# The site_author attribute. This is used in the HTML head element.
|
||||
#
|
||||
# Read more: https://zensical.org/docs/setup/basics/#site_author
|
||||
site_author = "d3vyce"
|
||||
|
||||
# The site_url is the canonical URL for your site. When building online
|
||||
# documentation you should set this.
|
||||
# Read more: https://zensical.org/docs/setup/basics/#site_url
|
||||
site_url = "https://fastapi-toolsets.d3vyce.fr"
|
||||
|
||||
# The copyright notice appears in the page footer and can contain an HTML
|
||||
# fragment.
|
||||
#
|
||||
# Read more: https://zensical.org/docs/setup/basics/#copyright
|
||||
copyright = """
|
||||
Copyright © 2026 d3vyce
|
||||
"""
|
||||
|
||||
copyright = "Copyright © 2026 d3vyce"
|
||||
repo_url = "https://github.com/d3vyce/fastapi-toolsets"
|
||||
|
||||
# Zensical supports both implicit navigation and explicitly defined navigation.
|
||||
# If you decide not to define a navigation here then Zensical will simply
|
||||
# derive the navigation structure from the directory structure of your
|
||||
# "docs_dir". The definition below demonstrates how a navigation structure
|
||||
# can be defined using TOML syntax.
|
||||
#
|
||||
# Read more: https://zensical.org/docs/setup/navigation/
|
||||
# nav = [
|
||||
# { "Get started" = "index.md" },
|
||||
# { "Markdown in 5min" = "markdown.md" },
|
||||
# ]
|
||||
|
||||
# With the "extra_css" option you can add your own CSS styling to customize
|
||||
# your Zensical project according to your needs. You can add any number of
|
||||
# CSS files.
|
||||
#
|
||||
# The path provided should be relative to the "docs_dir".
|
||||
#
|
||||
# Read more: https://zensical.org/docs/customization/#additional-css
|
||||
#
|
||||
#extra_css = ["stylesheets/extra.css"]
|
||||
|
||||
# With the `extra_javascript` option you can add your own JavaScript to your
|
||||
# project to customize the behavior according to your needs.
|
||||
#
|
||||
# The path provided should be relative to the "docs_dir".
|
||||
#
|
||||
# Read more: https://zensical.org/docs/customization/#additional-javascript
|
||||
#extra_javascript = ["javascripts/extra.js"]
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Section for configuring theme options
|
||||
# ----------------------------------------------------------------------------
|
||||
[project.theme]
|
||||
|
||||
# change this to "classic" to use the traditional Material for MkDocs look.
|
||||
#variant = "classic"
|
||||
|
||||
# Zensical allows you to override specific blocks, partials, or whole
|
||||
# templates as well as to define your own templates. To do this, uncomment
|
||||
# the custom_dir setting below and set it to a directory in which you
|
||||
# keep your template overrides.
|
||||
#
|
||||
# Read more:
|
||||
# - https://zensical.org/docs/customization/#extending-the-theme
|
||||
#
|
||||
custom_dir = "docs/overrides"
|
||||
|
||||
# With the "favicon" option you can set your own image to use as the icon
|
||||
# browsers will use in the browser title bar or tab bar. The path provided
|
||||
# must be relative to the "docs_dir".
|
||||
#
|
||||
# Read more:
|
||||
# - https://zensical.org/docs/setup/logo-and-icons/#favicon
|
||||
# - https://developer.mozilla.org/en-US/docs/Glossary/Favicon
|
||||
#
|
||||
#favicon = "images/favicon.png"
|
||||
|
||||
# Zensical supports more than 60 different languages. This means that the
|
||||
# labels and tooltips that Zensical's templates produce are translated.
|
||||
# The "language" option allows you to set the language used. This language
|
||||
# is also indicated in the HTML head element to help with accessibility
|
||||
# and guide search engines and translation tools.
|
||||
#
|
||||
# The default language is "en" (English). It is possible to create
|
||||
# sites with multiple languages and configure a language selector. See
|
||||
# the documentation for details.
|
||||
#
|
||||
# Read more:
|
||||
# - https://zensical.org/docs/setup/language/
|
||||
#
|
||||
language = "en"
|
||||
|
||||
# Zensical provides a number of feature toggles that change the behavior
|
||||
# of the documentation site.
|
||||
features = [
|
||||
# Zensical includes an announcement bar. This feature allows users to
|
||||
# dismiss it when they have read the announcement.
|
||||
# https://zensical.org/docs/setup/header/#announcement-bar
|
||||
"announce.dismiss",
|
||||
|
||||
# If you have a repository configured and turn on this feature, Zensical
|
||||
# will generate an edit button for the page. This works for common
|
||||
# repository hosting services.
|
||||
# https://zensical.org/docs/setup/repository/#content-actions
|
||||
#"content.action.edit",
|
||||
|
||||
# If you have a repository configured and turn on this feature, Zensical
|
||||
# will generate a button that allows the user to view the Markdown
|
||||
# code for the current page.
|
||||
# https://zensical.org/docs/setup/repository/#content-actions
|
||||
"content.action.view",
|
||||
|
||||
# Code annotations allow you to add an icon with a tooltip to your
|
||||
# code blocks to provide explanations at crucial points.
|
||||
# https://zensical.org/docs/authoring/code-blocks/#code-annotations
|
||||
"content.code.annotate",
|
||||
|
||||
# This feature turns on a button in code blocks that allow users to
|
||||
# copy the content to their clipboard without first selecting it.
|
||||
# https://zensical.org/docs/authoring/code-blocks/#code-copy-button
|
||||
"content.code.copy",
|
||||
|
||||
# Code blocks can include a button to allow for the selection of line
|
||||
# ranges by the user.
|
||||
# https://zensical.org/docs/authoring/code-blocks/#code-selection-button
|
||||
"content.code.select",
|
||||
|
||||
# Zensical can render footnotes as inline tooltips, so the user can read
|
||||
# the footnote without leaving the context of the document.
|
||||
# https://zensical.org/docs/authoring/footnotes/#footnote-tooltips
|
||||
"content.footnote.tooltips",
|
||||
|
||||
# If you have many content tabs that have the same titles (e.g., "Python",
|
||||
# "JavaScript", "Cobol"), this feature causes all of them to switch to
|
||||
# at the same time when the user chooses their language in one.
|
||||
# https://zensical.org/docs/authoring/content-tabs/#linked-content-tabs
|
||||
"content.tabs.link",
|
||||
|
||||
# With this feature enabled users can add tooltips to links that will be
|
||||
# displayed when the mouse pointer hovers the link.
|
||||
# https://zensical.org/docs/authoring/tooltips/#improved-tooltips
|
||||
"content.tooltips",
|
||||
|
||||
# With this feature enabled, Zensical will automatically hide parts
|
||||
# of the header when the user scrolls past a certain point.
|
||||
# https://zensical.org/docs/setup/header/#automatic-hiding
|
||||
# "header.autohide",
|
||||
|
||||
# Turn on this feature to expand all collapsible sections in the
|
||||
# navigation sidebar by default.
|
||||
# https://zensical.org/docs/setup/navigation/#navigation-expansion
|
||||
# "navigation.expand",
|
||||
|
||||
# This feature turns on navigation elements in the footer that allow the
|
||||
# user to navigate to a next or previous page.
|
||||
# https://zensical.org/docs/setup/footer/#navigation
|
||||
"navigation.footer",
|
||||
|
||||
# When section index pages are enabled, documents can be directly attached
|
||||
# to sections, which is particularly useful for providing overview pages.
|
||||
# https://zensical.org/docs/setup/navigation/#section-index-pages
|
||||
"navigation.indexes",
|
||||
|
||||
# When instant navigation is enabled, clicks on all internal links will be
|
||||
# intercepted and dispatched via XHR without fully reloading the page.
|
||||
# https://zensical.org/docs/setup/navigation/#instant-navigation
|
||||
"navigation.instant",
|
||||
|
||||
# With instant prefetching, your site will start to fetch a page once the
|
||||
# user hovers over a link. This will reduce the perceived loading time
|
||||
# for the user.
|
||||
# https://zensical.org/docs/setup/navigation/#instant-prefetching
|
||||
"navigation.instant.prefetch",
|
||||
|
||||
# In order to provide a better user experience on slow connections when
|
||||
# using instant navigation, a progress indicator can be enabled.
|
||||
# https://zensical.org/docs/setup/navigation/#progress-indicator
|
||||
#"navigation.instant.progress",
|
||||
|
||||
# When navigation paths are activated, a breadcrumb navigation is rendered
|
||||
# above the title of each page
|
||||
# https://zensical.org/docs/setup/navigation/#navigation-path
|
||||
"navigation.path",
|
||||
|
||||
# When pruning is enabled, only the visible navigation items are included
|
||||
# in the rendered HTML, reducing the size of the built site by 33% or more.
|
||||
# https://zensical.org/docs/setup/navigation/#navigation-pruning
|
||||
#"navigation.prune",
|
||||
|
||||
# When sections are enabled, top-level sections are rendered as groups in
|
||||
# the sidebar for viewports above 1220px, but remain as-is on mobile.
|
||||
# https://zensical.org/docs/setup/navigation/#navigation-sections
|
||||
"navigation.sections",
|
||||
|
||||
# When tabs are enabled, top-level sections are rendered in a menu layer
|
||||
# below the header for viewports above 1220px, but remain as-is on mobile.
|
||||
# https://zensical.org/docs/setup/navigation/#navigation-tabs
|
||||
"navigation.tabs",
|
||||
|
||||
# When sticky tabs are enabled, navigation tabs will lock below the header
|
||||
# and always remain visible when scrolling down.
|
||||
# https://zensical.org/docs/setup/navigation/#sticky-navigation-tabs
|
||||
#"navigation.tabs.sticky",
|
||||
|
||||
# A back-to-top button can be shown when the user, after scrolling down,
|
||||
# starts to scroll up again.
|
||||
# https://zensical.org/docs/setup/navigation/#back-to-top-button
|
||||
"navigation.top",
|
||||
|
||||
# When anchor tracking is enabled, the URL in the address bar is
|
||||
# automatically updated with the active anchor as highlighted in the table
|
||||
# of contents.
|
||||
# https://zensical.org/docs/setup/navigation/#anchor-tracking
|
||||
"navigation.tracking",
|
||||
|
||||
# When search highlighting is enabled and a user clicks on a search result,
|
||||
# Zensical will highlight all occurrences after following the link.
|
||||
# https://zensical.org/docs/setup/search/#search-highlighting
|
||||
"search.highlight",
|
||||
|
||||
# When anchor following for the table of contents is enabled, the sidebar
|
||||
# is automatically scrolled so that the active anchor is always visible.
|
||||
# https://zensical.org/docs/setup/navigation/#anchor-following
|
||||
# "toc.follow",
|
||||
|
||||
# When navigation integration for the table of contents is enabled, it is
|
||||
# always rendered as part of the navigation sidebar on the left.
|
||||
# https://zensical.org/docs/setup/navigation/#navigation-integration
|
||||
#"toc.integrate",
|
||||
]
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# In the "palette" subsection you can configure options for the color scheme.
|
||||
# You can configure different color # schemes, e.g., to turn on dark mode,
|
||||
# that the user can switch between. Each color scheme can be further
|
||||
# customized.
|
||||
#
|
||||
# Read more:
|
||||
# - https://zensical.org/docs/setup/colors/
|
||||
# ----------------------------------------------------------------------------
|
||||
[[project.theme.palette]]
|
||||
scheme = "default"
|
||||
toggle.icon = "lucide/sun"
|
||||
@@ -270,43 +40,13 @@ scheme = "slate"
|
||||
toggle.icon = "lucide/moon"
|
||||
toggle.name = "Switch to light mode"
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# In the "font" subsection you can configure the fonts used. By default, fonts
|
||||
# are loaded from Google Fonts, giving you a wide range of choices from a set
|
||||
# of suitably licensed fonts. There are options for a normal text font and for
|
||||
# a monospaced font used in code blocks.
|
||||
# ----------------------------------------------------------------------------
|
||||
[project.theme.font]
|
||||
text = "Inter"
|
||||
code = "Jetbrains Mono"
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# You can configure your own logo to be shown in the header using the "logo"
|
||||
# option in the "icons" subsection. The logo can be a path to a file in your
|
||||
# "docs_dir" or it can be a path to an icon.
|
||||
#
|
||||
# Likewise, you can customize the logo used for the repository section of the
|
||||
# header. Zensical derives the default logo for this from the repository URL.
|
||||
# See below...
|
||||
#
|
||||
# There are other icons you can customize. See the documentation for details.
|
||||
#
|
||||
# Read more:
|
||||
# - https://zensical.org/docs/setup/logo-and-icons
|
||||
# - https://zensical.org/docs/authoring/icons-emojis/#search
|
||||
# ----------------------------------------------------------------------------
|
||||
[project.theme.icon]
|
||||
#logo = "lucide/smile"
|
||||
repo = "fontawesome/brands/github"
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# The "extra" section contains miscellaneous settings.
|
||||
# ----------------------------------------------------------------------------
|
||||
#[[project.extra.social]]
|
||||
#icon = "fontawesome/brands/github"
|
||||
#link = "https://github.com/user/repo"
|
||||
|
||||
|
||||
[project.plugins.mkdocstrings.handlers.python]
|
||||
inventories = ["https://docs.python.org/3/objects.inv"]
|
||||
paths = ["src"]
|
||||
@@ -316,3 +56,42 @@ docstring_style = "google"
|
||||
inherited_members = true
|
||||
show_source = false
|
||||
show_root_heading = true
|
||||
|
||||
[project.markdown_extensions]
|
||||
abbr = {}
|
||||
admonition = {}
|
||||
attr_list = {}
|
||||
def_list = {}
|
||||
footnotes = {}
|
||||
md_in_html = {}
|
||||
"pymdownx.arithmatex" = {generic = true}
|
||||
"pymdownx.betterem" = {}
|
||||
"pymdownx.caret" = {}
|
||||
"pymdownx.details" = {}
|
||||
"pymdownx.emoji" = {}
|
||||
"pymdownx.inlinehilite" = {}
|
||||
"pymdownx.keys" = {}
|
||||
"pymdownx.magiclink" = {}
|
||||
"pymdownx.mark" = {}
|
||||
"pymdownx.smartsymbols" = {}
|
||||
"pymdownx.tasklist" = {custom_checkbox = true}
|
||||
"pymdownx.tilde" = {}
|
||||
|
||||
[project.markdown_extensions."pymdownx.highlight"]
|
||||
anchor_linenums = true
|
||||
line_spans = "__span"
|
||||
pygments_lang_class = true
|
||||
|
||||
[project.markdown_extensions."pymdownx.superfences"]
|
||||
custom_fences = [{name = "mermaid", class = "mermaid"}]
|
||||
|
||||
[project.markdown_extensions."pymdownx.tabbed"]
|
||||
alternate_style = true
|
||||
combine_header_slug = true
|
||||
|
||||
[project.markdown_extensions."toc"]
|
||||
permalink = true
|
||||
|
||||
[project.markdown_extensions."pymdownx.snippets"]
|
||||
base_path = ["."]
|
||||
check_paths = true
|
||||
|
||||
Reference in New Issue
Block a user