mirror of
https://github.com/d3vyce/fastapi-toolsets.git
synced 2026-08-14 19:58:39 +00:00
Compare commits
1
Commits
v2.0.0
..
6ea918d956
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ea918d956
|
@@ -20,7 +20,7 @@ A modular collection of production-ready utilities for FastAPI. Install only wha
|
|||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
The base package includes the core modules (CRUD, database, schemas, exceptions, fixtures, dependencies, model mixins, logging):
|
The base package includes the core modules (CRUD, database, schemas, exceptions, fixtures, dependencies, logging):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv add fastapi-toolsets
|
uv add fastapi-toolsets
|
||||||
@@ -29,9 +29,9 @@ uv add fastapi-toolsets
|
|||||||
Install only the extras you need:
|
Install only the extras you need:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv add "fastapi-toolsets[cli]"
|
uv add "fastapi-toolsets[cli]" # CLI (typer)
|
||||||
uv add "fastapi-toolsets[metrics]"
|
uv add "fastapi-toolsets[metrics]" # Prometheus metrics (prometheus_client)
|
||||||
uv add "fastapi-toolsets[pytest]"
|
uv add "fastapi-toolsets[pytest]" # Pytest helpers (httpx, pytest-xdist)
|
||||||
```
|
```
|
||||||
|
|
||||||
Or install everything:
|
Or install everything:
|
||||||
@@ -48,7 +48,6 @@ uv add "fastapi-toolsets[all]"
|
|||||||
- **Database**: Session management, transaction helpers, table locking, and polling-based row change detection
|
- **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
|
- **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
|
- **Fixtures**: Fixture system with dependency management, context support, and pytest integration
|
||||||
- **Model Mixins**: SQLAlchemy mixins for common column patterns (`UUIDMixin`, `CreatedAtMixin`, `UpdatedAtMixin`, `TimestampMixin`)
|
|
||||||
- **Standardized API Responses**: Consistent response format with `Response`, `PaginatedResponse`, and `PydanticBase`
|
- **Standardized API Responses**: Consistent response format with `Response`, `PaginatedResponse`, and `PydanticBase`
|
||||||
- **Exception Handling**: Structured error responses with automatic OpenAPI documentation
|
- **Exception Handling**: Structured error responses with automatic OpenAPI documentation
|
||||||
- **Logging**: Logging configuration with uvicorn integration via `configure_logging` and `get_logger`
|
- **Logging**: Logging configuration with uvicorn integration via `configure_logging` and `get_logger`
|
||||||
|
|||||||
+5
-6
@@ -20,7 +20,7 @@ A modular collection of production-ready utilities for FastAPI. Install only wha
|
|||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
The base package includes the core modules (CRUD, database, schemas, exceptions, fixtures, dependencies, model mixins, logging):
|
The base package includes the core modules (CRUD, database, schemas, exceptions, fixtures, dependencies, logging):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv add fastapi-toolsets
|
uv add fastapi-toolsets
|
||||||
@@ -29,9 +29,9 @@ uv add fastapi-toolsets
|
|||||||
Install only the extras you need:
|
Install only the extras you need:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv add "fastapi-toolsets[cli]"
|
uv add "fastapi-toolsets[cli]" # CLI (typer)
|
||||||
uv add "fastapi-toolsets[metrics]"
|
uv add "fastapi-toolsets[metrics]" # Prometheus metrics (prometheus_client)
|
||||||
uv add "fastapi-toolsets[pytest]"
|
uv add "fastapi-toolsets[pytest]" # Pytest helpers (httpx, pytest-xdist)
|
||||||
```
|
```
|
||||||
|
|
||||||
Or install everything:
|
Or install everything:
|
||||||
@@ -44,11 +44,10 @@ uv add "fastapi-toolsets[all]"
|
|||||||
|
|
||||||
### Core
|
### Core
|
||||||
|
|
||||||
- **CRUD**: Generic async CRUD operations with `CrudFactory`, built-in full-text/faceted search and Offset/Cursor pagination.
|
- **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
|
- **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
|
- **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
|
- **Fixtures**: Fixture system with dependency management, context support, and pytest integration
|
||||||
- **Model Mixins**: SQLAlchemy mixins for common column patterns (`UUIDMixin`, `CreatedAtMixin`, `UpdatedAtMixin`, `TimestampMixin`)
|
|
||||||
- **Standardized API Responses**: Consistent response format with `Response`, `PaginatedResponse`, and `PydanticBase`
|
- **Standardized API Responses**: Consistent response format with `Response`, `PaginatedResponse`, and `PydanticBase`
|
||||||
- **Exception Handling**: Structured error responses with automatic OpenAPI documentation
|
- **Exception Handling**: Structured error responses with automatic OpenAPI documentation
|
||||||
- **Logging**: Logging configuration with uvicorn integration via `configure_logging` and `get_logger`
|
- **Logging**: Logging configuration with uvicorn integration via `configure_logging` and `get_logger`
|
||||||
|
|||||||
@@ -1,137 +0,0 @@
|
|||||||
# Migrating to v2.0
|
|
||||||
|
|
||||||
This page covers every breaking change introduced in **v2.0** and the steps required to update your code.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## CRUD
|
|
||||||
|
|
||||||
### `schema` is now required in `offset_paginate()` and `cursor_paginate()`
|
|
||||||
|
|
||||||
Calls that omit `schema` will now raise a `TypeError` at runtime.
|
|
||||||
|
|
||||||
Previously `schema` was optional; omitting it returned raw SQLAlchemy model instances inside the response. It is now a required keyword argument and the response always contains serialized schema instances.
|
|
||||||
|
|
||||||
=== "Before (`v1`)"
|
|
||||||
|
|
||||||
```python
|
|
||||||
# schema omitted — returned raw model instances
|
|
||||||
result = await UserCrud.offset_paginate(session=session, page=1)
|
|
||||||
result = await UserCrud.cursor_paginate(session=session, cursor=token)
|
|
||||||
```
|
|
||||||
|
|
||||||
=== "Now (`v2`)"
|
|
||||||
|
|
||||||
```python
|
|
||||||
result = await UserCrud.offset_paginate(session=session, page=1, schema=UserRead)
|
|
||||||
result = await UserCrud.cursor_paginate(session=session, cursor=token, schema=UserRead)
|
|
||||||
```
|
|
||||||
|
|
||||||
### `as_response` removed from `create()`, `get()`, and `update()`
|
|
||||||
|
|
||||||
Passing `as_response` to these methods will raise a `TypeError` at runtime.
|
|
||||||
|
|
||||||
The `as_response=True` shorthand is replaced by passing a `schema` directly. The return value is a `Response[schema]` when `schema` is provided, or the raw model instance when it is not.
|
|
||||||
|
|
||||||
=== "Before (`v1`)"
|
|
||||||
|
|
||||||
```python
|
|
||||||
user = await UserCrud.create(session=session, obj=data, as_response=True)
|
|
||||||
user = await UserCrud.get(session=session, filters=filters, as_response=True)
|
|
||||||
user = await UserCrud.update(session=session, obj=data, filters, as_response=True)
|
|
||||||
```
|
|
||||||
|
|
||||||
=== "Now (`v2`)"
|
|
||||||
|
|
||||||
```python
|
|
||||||
user = await UserCrud.create(session=session, obj=data, schema=UserRead)
|
|
||||||
user = await UserCrud.get(session=session, filters=filters, schema=UserRead)
|
|
||||||
user = await UserCrud.update(session=session, obj=data, filters, schema=UserRead)
|
|
||||||
```
|
|
||||||
|
|
||||||
### `delete()`: `as_response` renamed and return type changed
|
|
||||||
|
|
||||||
`as_response` is gone, and the plain (non-response) call no longer returns `True`.
|
|
||||||
|
|
||||||
Two changes were made to `delete()`:
|
|
||||||
|
|
||||||
1. The `as_response` parameter is renamed to `return_response`.
|
|
||||||
2. When called without `return_response=True`, the method now returns `None` on success instead of `True`.
|
|
||||||
|
|
||||||
=== "Before (`v1`)"
|
|
||||||
|
|
||||||
```python
|
|
||||||
ok = await UserCrud.delete(session=session, filters=filters)
|
|
||||||
if ok: # True on success
|
|
||||||
...
|
|
||||||
|
|
||||||
response = await UserCrud.delete(session=session, filters=filters, as_response=True)
|
|
||||||
```
|
|
||||||
|
|
||||||
=== "Now (`v2`)"
|
|
||||||
|
|
||||||
```python
|
|
||||||
await UserCrud.delete(session=session, filters=filters) # returns None
|
|
||||||
|
|
||||||
response = await UserCrud.delete(session=session, filters=filters, return_response=True)
|
|
||||||
```
|
|
||||||
|
|
||||||
### `paginate()` alias removed
|
|
||||||
|
|
||||||
Any call to `crud.paginate(...)` will raise `AttributeError` at runtime.
|
|
||||||
|
|
||||||
The `paginate` shorthand was an alias for `offset_paginate`. It has been removed; call `offset_paginate` directly.
|
|
||||||
|
|
||||||
=== "Before (`v1`)"
|
|
||||||
|
|
||||||
```python
|
|
||||||
result = await UserCrud.paginate(session=session, page=2, items_per_page=20, schema=UserRead)
|
|
||||||
```
|
|
||||||
|
|
||||||
=== "Now (`v2`)"
|
|
||||||
|
|
||||||
```python
|
|
||||||
result = await UserCrud.offset_paginate(session=session, page=2, items_per_page=20, schema=UserRead)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Exceptions
|
|
||||||
|
|
||||||
### Missing `api_error` raises `TypeError` at class definition time
|
|
||||||
|
|
||||||
Unfinished or stub exception subclasses that previously compiled fine will now fail on import.
|
|
||||||
|
|
||||||
In `v1`, a subclass without `api_error` would only fail when the exception was raised. In `v2`, `__init_subclass__` validates this at class definition time.
|
|
||||||
|
|
||||||
=== "Before (`v1`)"
|
|
||||||
|
|
||||||
```python
|
|
||||||
class MyError(ApiException):
|
|
||||||
pass # fine until raised
|
|
||||||
```
|
|
||||||
|
|
||||||
=== "Now (`v2`)"
|
|
||||||
|
|
||||||
```python
|
|
||||||
class MyError(ApiException):
|
|
||||||
pass # TypeError: MyError must define an 'api_error' class attribute.
|
|
||||||
```
|
|
||||||
|
|
||||||
For shared base classes that are not meant to be raised directly, use `abstract=True`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
class BillingError(ApiException, abstract=True):
|
|
||||||
"""Base for all billing-related errors — not raised directly."""
|
|
||||||
|
|
||||||
class PaymentRequiredError(BillingError):
|
|
||||||
api_error = ApiError(code=402, msg="Payment Required", desc="...", err_code="BILLING-402")
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Schemas
|
|
||||||
|
|
||||||
### `Pagination` alias removed
|
|
||||||
|
|
||||||
`Pagination` was already deprecated in `v1` and is fully removed in `v2`, you now need to use [`OffsetPagination`](../reference/schemas.md#fastapi_toolsets.schemas.OffsetPagination) or [`CursorPagination`](../reference/schemas.md#fastapi_toolsets.schemas.CursorPagination).
|
|
||||||
@@ -95,6 +95,9 @@ The [`offset_paginate`](../reference/crud.md#fastapi_toolsets.crud.factory.Async
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
!!! warning "Deprecated: `paginate`"
|
||||||
|
The `paginate` function is a backward-compatible alias for `offset_paginate`. This function is **deprecated** and will be removed in **v2.0**.
|
||||||
|
|
||||||
### Cursor pagination
|
### Cursor pagination
|
||||||
|
|
||||||
```python
|
```python
|
||||||
@@ -468,6 +471,9 @@ async def list_users(session: SessionDep, page: int = 1) -> PaginatedResponse[Us
|
|||||||
|
|
||||||
The schema must have `from_attributes=True` (or inherit from [`PydanticBase`](../reference/schemas.md#fastapi_toolsets.schemas.PydanticBase)) so it can be built from SQLAlchemy model instances.
|
The schema must have `from_attributes=True` (or inherit from [`PydanticBase`](../reference/schemas.md#fastapi_toolsets.schemas.PydanticBase)) so it can be built from SQLAlchemy model instances.
|
||||||
|
|
||||||
|
!!! warning "Deprecated: `as_response`"
|
||||||
|
The `as_response=True` parameter is **deprecated** and will be removed in **v2.0**. Replace it with `schema=YourSchema`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
[:material-api: API Reference](../reference/crud.md)
|
[:material-api: API Reference](../reference/crud.md)
|
||||||
|
|||||||
+3
-27
@@ -22,20 +22,16 @@ async def get_user(user: User = UserDep) -> Response[UserSchema]:
|
|||||||
|
|
||||||
### [`PaginatedResponse[T]`](../reference/schemas.md#fastapi_toolsets.schemas.PaginatedResponse)
|
### [`PaginatedResponse[T]`](../reference/schemas.md#fastapi_toolsets.schemas.PaginatedResponse)
|
||||||
|
|
||||||
Wraps a list of items with pagination metadata and optional facet values. The `pagination` field accepts either [`OffsetPagination`](../reference/schemas.md#fastapi_toolsets.schemas.OffsetPagination) or [`CursorPagination`](../reference/schemas.md#fastapi_toolsets.schemas.CursorPagination) depending on the strategy used.
|
Wraps a list of items with pagination metadata and optional facet values.
|
||||||
|
|
||||||
#### [`OffsetPagination`](../reference/schemas.md#fastapi_toolsets.schemas.OffsetPagination)
|
|
||||||
|
|
||||||
Page-number based. Requires `total_count` so clients can compute the total number of pages.
|
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from fastapi_toolsets.schemas import PaginatedResponse, OffsetPagination
|
from fastapi_toolsets.schemas import PaginatedResponse, Pagination
|
||||||
|
|
||||||
@router.get("/users")
|
@router.get("/users")
|
||||||
async def list_users() -> PaginatedResponse[UserSchema]:
|
async def list_users() -> PaginatedResponse[UserSchema]:
|
||||||
return PaginatedResponse(
|
return PaginatedResponse(
|
||||||
data=users,
|
data=users,
|
||||||
pagination=OffsetPagination(
|
pagination=Pagination(
|
||||||
total_count=100,
|
total_count=100,
|
||||||
items_per_page=10,
|
items_per_page=10,
|
||||||
page=1,
|
page=1,
|
||||||
@@ -44,26 +40,6 @@ async def list_users() -> PaginatedResponse[UserSchema]:
|
|||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
#### [`CursorPagination`](../reference/schemas.md#fastapi_toolsets.schemas.CursorPagination)
|
|
||||||
|
|
||||||
Cursor based. Efficient for large or frequently updated datasets where offset pagination is impractical. Provides opaque `next_cursor` / `prev_cursor` tokens; no total count is exposed.
|
|
||||||
|
|
||||||
```python
|
|
||||||
from fastapi_toolsets.schemas import PaginatedResponse, CursorPagination
|
|
||||||
|
|
||||||
@router.get("/events")
|
|
||||||
async def list_events() -> PaginatedResponse[EventSchema]:
|
|
||||||
return PaginatedResponse(
|
|
||||||
data=events,
|
|
||||||
pagination=CursorPagination(
|
|
||||||
next_cursor="eyJpZCI6IDQyfQ==",
|
|
||||||
prev_cursor=None,
|
|
||||||
items_per_page=20,
|
|
||||||
has_more=True,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
The optional `filter_attributes` field is populated when `facet_fields` are configured on the CRUD class (see [Filter attributes](crud.md#filter-attributes-facets)). It is `None` by default and can be hidden from API responses with `response_model_exclude_none=True`.
|
The optional `filter_attributes` field is populated when `facet_fields` are configured on the CRUD class (see [Filter attributes](crud.md#filter-attributes-facets)). It is `None` by default and can be hidden from API responses with `response_model_exclude_none=True`.
|
||||||
|
|
||||||
### [`ErrorResponse`](../reference/schemas.md#fastapi_toolsets.schemas.ErrorResponse)
|
### [`ErrorResponse`](../reference/schemas.md#fastapi_toolsets.schemas.ErrorResponse)
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
# `schemas`
|
# `schemas` module
|
||||||
|
|
||||||
Here's the reference for all response models and types provided by the `schemas` module.
|
Here's the reference for all response models and types provided by the `schemas` module.
|
||||||
|
|
||||||
@@ -12,8 +12,7 @@ from fastapi_toolsets.schemas import (
|
|||||||
BaseResponse,
|
BaseResponse,
|
||||||
Response,
|
Response,
|
||||||
ErrorResponse,
|
ErrorResponse,
|
||||||
OffsetPagination,
|
Pagination,
|
||||||
CursorPagination,
|
|
||||||
PaginatedResponse,
|
PaginatedResponse,
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
@@ -30,8 +29,6 @@ from fastapi_toolsets.schemas import (
|
|||||||
|
|
||||||
## ::: fastapi_toolsets.schemas.ErrorResponse
|
## ::: fastapi_toolsets.schemas.ErrorResponse
|
||||||
|
|
||||||
## ::: fastapi_toolsets.schemas.OffsetPagination
|
## ::: fastapi_toolsets.schemas.Pagination
|
||||||
|
|
||||||
## ::: fastapi_toolsets.schemas.CursorPagination
|
|
||||||
|
|
||||||
## ::: fastapi_toolsets.schemas.PaginatedResponse
|
## ::: fastapi_toolsets.schemas.PaginatedResponse
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# `security`
|
||||||
|
|
||||||
|
Here's the reference for the authentication helpers provided by the `security` module.
|
||||||
|
|
||||||
|
You can import them directly from `fastapi_toolsets.security`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi_toolsets.security import (
|
||||||
|
AuthSource,
|
||||||
|
BearerTokenAuth,
|
||||||
|
CookieAuth,
|
||||||
|
OAuth2Auth,
|
||||||
|
OpenIDAuth,
|
||||||
|
MultiAuth,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## ::: fastapi_toolsets.security.AuthSource
|
||||||
|
|
||||||
|
## ::: fastapi_toolsets.security.BearerTokenAuth
|
||||||
|
|
||||||
|
## ::: fastapi_toolsets.security.CookieAuth
|
||||||
|
|
||||||
|
## ::: fastapi_toolsets.security.OAuth2Auth
|
||||||
|
|
||||||
|
## ::: fastapi_toolsets.security.OpenIDAuth
|
||||||
|
|
||||||
|
## ::: fastapi_toolsets.security.MultiAuth
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "fastapi-toolsets"
|
name = "fastapi-toolsets"
|
||||||
version = "2.0.0"
|
version = "1.3.0"
|
||||||
description = "Production-ready utilities for FastAPI applications"
|
description = "Production-ready utilities for FastAPI applications"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|||||||
@@ -21,4 +21,4 @@ Example usage:
|
|||||||
return Response(data={"user": user.username}, message="Success")
|
return Response(data={"user": user.username}, message="Success")
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__version__ = "2.0.0"
|
__version__ = "1.3.0"
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ async def load(
|
|||||||
registry = get_fixtures_registry()
|
registry = get_fixtures_registry()
|
||||||
db_context = get_db_context()
|
db_context = get_db_context()
|
||||||
|
|
||||||
context_list = list(contexts) if contexts else [Context.BASE]
|
context_list = [c.value for c in contexts] if contexts else [Context.BASE]
|
||||||
|
|
||||||
ordered = registry.resolve_context_dependencies(*context_list)
|
ordered = registry.resolve_context_dependencies(*context_list)
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,12 @@
|
|||||||
"""Generic async CRUD operations for SQLAlchemy models."""
|
"""Generic async CRUD operations for SQLAlchemy models."""
|
||||||
|
|
||||||
from ..exceptions import InvalidFacetFilterError, NoSearchableFieldsError
|
from ..exceptions import InvalidFacetFilterError, NoSearchableFieldsError
|
||||||
from ..types import (
|
from .factory import CrudFactory, JoinType, M2MFieldType, OrderByClause
|
||||||
|
from .search import (
|
||||||
FacetFieldType,
|
FacetFieldType,
|
||||||
JoinType,
|
SearchConfig,
|
||||||
M2MFieldType,
|
get_searchable_fields,
|
||||||
OrderByClause,
|
|
||||||
SearchFieldType,
|
|
||||||
)
|
)
|
||||||
from .factory import CrudFactory
|
|
||||||
from .search import SearchConfig, get_searchable_fields
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"CrudFactory",
|
"CrudFactory",
|
||||||
@@ -21,5 +18,4 @@ __all__ = [
|
|||||||
"NoSearchableFieldsError",
|
"NoSearchableFieldsError",
|
||||||
"OrderByClause",
|
"OrderByClause",
|
||||||
"SearchConfig",
|
"SearchConfig",
|
||||||
"SearchFieldType",
|
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -6,10 +6,11 @@ import base64
|
|||||||
import inspect
|
import inspect
|
||||||
import json
|
import json
|
||||||
import uuid as uuid_module
|
import uuid as uuid_module
|
||||||
from collections.abc import Awaitable, Callable, Sequence
|
import warnings
|
||||||
|
from collections.abc import Awaitable, Callable, Mapping, Sequence
|
||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from typing import Any, ClassVar, Generic, Literal, Self, cast, overload
|
from typing import Any, ClassVar, Generic, Literal, Self, TypeVar, cast, overload
|
||||||
|
|
||||||
from fastapi import Query
|
from fastapi import Query
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
@@ -20,28 +21,28 @@ from sqlalchemy.exc import NoResultFound
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import DeclarativeBase, QueryableAttribute, selectinload
|
from sqlalchemy.orm import DeclarativeBase, QueryableAttribute, selectinload
|
||||||
from sqlalchemy.sql.base import ExecutableOption
|
from sqlalchemy.sql.base import ExecutableOption
|
||||||
|
from sqlalchemy.sql.elements import ColumnElement
|
||||||
from sqlalchemy.sql.roles import WhereHavingRole
|
from sqlalchemy.sql.roles import WhereHavingRole
|
||||||
|
|
||||||
from ..db import get_transaction
|
from ..db import get_transaction
|
||||||
from ..exceptions import InvalidOrderFieldError, NotFoundError
|
from ..exceptions import InvalidOrderFieldError, NotFoundError
|
||||||
from ..schemas import CursorPagination, OffsetPagination, PaginatedResponse, Response
|
from ..schemas import CursorPagination, OffsetPagination, PaginatedResponse, Response
|
||||||
from ..types import (
|
|
||||||
FacetFieldType,
|
|
||||||
JoinType,
|
|
||||||
M2MFieldType,
|
|
||||||
ModelType,
|
|
||||||
OrderByClause,
|
|
||||||
SchemaType,
|
|
||||||
SearchFieldType,
|
|
||||||
)
|
|
||||||
from .search import (
|
from .search import (
|
||||||
|
FacetFieldType,
|
||||||
SearchConfig,
|
SearchConfig,
|
||||||
|
SearchFieldType,
|
||||||
build_facets,
|
build_facets,
|
||||||
build_filter_by,
|
build_filter_by,
|
||||||
build_search_filters,
|
build_search_filters,
|
||||||
facet_keys,
|
facet_keys,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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:
|
def _encode_cursor(value: Any) -> str:
|
||||||
"""Encode cursor column value as an base64 string."""
|
"""Encode cursor column value as an base64 string."""
|
||||||
@@ -53,22 +54,6 @@ def _decode_cursor(cursor: str) -> str:
|
|||||||
return json.loads(base64.b64decode(cursor.encode()).decode())
|
return json.loads(base64.b64decode(cursor.encode()).decode())
|
||||||
|
|
||||||
|
|
||||||
def _apply_joins(q: Any, joins: JoinType | None, outer_join: bool) -> Any:
|
|
||||||
"""Apply a list of (model, condition) joins to a SQLAlchemy select query."""
|
|
||||||
if not joins:
|
|
||||||
return q
|
|
||||||
for model, condition in joins:
|
|
||||||
q = q.outerjoin(model, condition) if outer_join else q.join(model, condition)
|
|
||||||
return q
|
|
||||||
|
|
||||||
|
|
||||||
def _apply_search_joins(q: Any, search_joins: list[Any]) -> Any:
|
|
||||||
"""Apply relationship-based outer joins (from search/filter_by) to a query."""
|
|
||||||
for join_rel in search_joins:
|
|
||||||
q = q.outerjoin(join_rel)
|
|
||||||
return q
|
|
||||||
|
|
||||||
|
|
||||||
class AsyncCrud(Generic[ModelType]):
|
class AsyncCrud(Generic[ModelType]):
|
||||||
"""Generic async CRUD operations for SQLAlchemy models.
|
"""Generic async CRUD operations for SQLAlchemy models.
|
||||||
|
|
||||||
@@ -148,48 +133,6 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
return set()
|
return set()
|
||||||
return set(cls.m2m_fields.keys())
|
return set(cls.m2m_fields.keys())
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _resolve_facet_fields(
|
|
||||||
cls: type[Self],
|
|
||||||
facet_fields: Sequence[FacetFieldType] | None,
|
|
||||||
) -> Sequence[FacetFieldType] | None:
|
|
||||||
"""Return facet_fields if given, otherwise fall back to the class-level default."""
|
|
||||||
return facet_fields if facet_fields is not None else cls.facet_fields
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _prepare_filter_by(
|
|
||||||
cls: type[Self],
|
|
||||||
filter_by: dict[str, Any] | BaseModel | None,
|
|
||||||
facet_fields: Sequence[FacetFieldType] | None,
|
|
||||||
) -> tuple[list[Any], list[Any]]:
|
|
||||||
"""Normalize filter_by and return (filters, joins) to apply to the query."""
|
|
||||||
if isinstance(filter_by, BaseModel):
|
|
||||||
filter_by = filter_by.model_dump(exclude_none=True)
|
|
||||||
if not filter_by:
|
|
||||||
return [], []
|
|
||||||
resolved = cls._resolve_facet_fields(facet_fields)
|
|
||||||
return build_filter_by(filter_by, resolved or [])
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def _build_filter_attributes(
|
|
||||||
cls: type[Self],
|
|
||||||
session: AsyncSession,
|
|
||||||
facet_fields: Sequence[FacetFieldType] | None,
|
|
||||||
filters: list[Any],
|
|
||||||
search_joins: list[Any],
|
|
||||||
) -> dict[str, list[Any]] | None:
|
|
||||||
"""Build facet filter_attributes, or return None if no facet fields configured."""
|
|
||||||
resolved = cls._resolve_facet_fields(facet_fields)
|
|
||||||
if not resolved:
|
|
||||||
return None
|
|
||||||
return await build_facets(
|
|
||||||
session,
|
|
||||||
cls.model,
|
|
||||||
resolved,
|
|
||||||
base_filters=filters,
|
|
||||||
base_joins=search_joins,
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def filter_params(
|
def filter_params(
|
||||||
cls: type[Self],
|
cls: type[Self],
|
||||||
@@ -210,7 +153,7 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
ValueError: If no facet fields are configured on this CRUD class and none are
|
ValueError: If no facet fields are configured on this CRUD class and none are
|
||||||
provided via ``facet_fields``.
|
provided via ``facet_fields``.
|
||||||
"""
|
"""
|
||||||
fields = cls._resolve_facet_fields(facet_fields)
|
fields = facet_fields if facet_fields is not None else cls.facet_fields
|
||||||
if not fields:
|
if not fields:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"{cls.__name__} has no facet_fields configured. "
|
f"{cls.__name__} has no facet_fields configured. "
|
||||||
@@ -301,8 +244,21 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
obj: BaseModel,
|
obj: BaseModel,
|
||||||
*,
|
*,
|
||||||
schema: type[SchemaType],
|
schema: type[SchemaType],
|
||||||
|
as_response: bool = ...,
|
||||||
) -> Response[SchemaType]: ...
|
) -> Response[SchemaType]: ...
|
||||||
|
|
||||||
|
# Backward-compatible - will be removed in v2.0
|
||||||
|
@overload
|
||||||
|
@classmethod
|
||||||
|
async def create( # pragma: no cover
|
||||||
|
cls: type[Self],
|
||||||
|
session: AsyncSession,
|
||||||
|
obj: BaseModel,
|
||||||
|
*,
|
||||||
|
as_response: Literal[True],
|
||||||
|
schema: None = ...,
|
||||||
|
) -> Response[ModelType]: ...
|
||||||
|
|
||||||
@overload
|
@overload
|
||||||
@classmethod
|
@classmethod
|
||||||
async def create( # pragma: no cover
|
async def create( # pragma: no cover
|
||||||
@@ -310,6 +266,7 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
obj: BaseModel,
|
obj: BaseModel,
|
||||||
*,
|
*,
|
||||||
|
as_response: Literal[False] = ...,
|
||||||
schema: None = ...,
|
schema: None = ...,
|
||||||
) -> ModelType: ...
|
) -> ModelType: ...
|
||||||
|
|
||||||
@@ -319,19 +276,29 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
obj: BaseModel,
|
obj: BaseModel,
|
||||||
*,
|
*,
|
||||||
|
as_response: bool = False,
|
||||||
schema: type[BaseModel] | None = None,
|
schema: type[BaseModel] | None = None,
|
||||||
) -> ModelType | Response[Any]:
|
) -> ModelType | Response[ModelType] | Response[Any]:
|
||||||
"""Create a new record in the database.
|
"""Create a new record in the database.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
session: DB async session
|
session: DB async session
|
||||||
obj: Pydantic model with data to create
|
obj: Pydantic model with data to create
|
||||||
|
as_response: Deprecated. Use ``schema`` instead. Will be removed in v2.0.
|
||||||
schema: Pydantic schema to serialize the result into. When provided,
|
schema: Pydantic schema to serialize the result into. When provided,
|
||||||
the result is automatically wrapped in a ``Response[schema]``.
|
the result is automatically wrapped in a ``Response[schema]``.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Created model instance, or ``Response[schema]`` when ``schema`` is given.
|
Created model instance, or ``Response[schema]`` when ``schema`` is given,
|
||||||
|
or ``Response[ModelType]`` when ``as_response=True`` (deprecated).
|
||||||
"""
|
"""
|
||||||
|
if as_response and schema is None:
|
||||||
|
warnings.warn(
|
||||||
|
"as_response is deprecated and will be removed in v2.0. "
|
||||||
|
"Use schema=YourSchema instead.",
|
||||||
|
DeprecationWarning,
|
||||||
|
stacklevel=2,
|
||||||
|
)
|
||||||
async with get_transaction(session):
|
async with get_transaction(session):
|
||||||
m2m_exclude = cls._m2m_schema_fields()
|
m2m_exclude = cls._m2m_schema_fields()
|
||||||
data = (
|
data = (
|
||||||
@@ -347,8 +314,9 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
session.add(db_model)
|
session.add(db_model)
|
||||||
await session.refresh(db_model)
|
await session.refresh(db_model)
|
||||||
result = cast(ModelType, db_model)
|
result = cast(ModelType, db_model)
|
||||||
if schema:
|
if as_response or schema:
|
||||||
return Response(data=schema.model_validate(result))
|
data_out = schema.model_validate(result) if schema else result
|
||||||
|
return Response(data=data_out)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@overload
|
@overload
|
||||||
@@ -363,8 +331,25 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
with_for_update: bool = False,
|
with_for_update: bool = False,
|
||||||
load_options: list[ExecutableOption] | None = None,
|
load_options: list[ExecutableOption] | None = None,
|
||||||
schema: type[SchemaType],
|
schema: type[SchemaType],
|
||||||
|
as_response: bool = ...,
|
||||||
) -> Response[SchemaType]: ...
|
) -> Response[SchemaType]: ...
|
||||||
|
|
||||||
|
# Backward-compatible - will be removed in v2.0
|
||||||
|
@overload
|
||||||
|
@classmethod
|
||||||
|
async def get( # pragma: no cover
|
||||||
|
cls: type[Self],
|
||||||
|
session: AsyncSession,
|
||||||
|
filters: list[Any],
|
||||||
|
*,
|
||||||
|
joins: JoinType | None = None,
|
||||||
|
outer_join: bool = False,
|
||||||
|
with_for_update: bool = False,
|
||||||
|
load_options: list[ExecutableOption] | None = None,
|
||||||
|
as_response: Literal[True],
|
||||||
|
schema: None = ...,
|
||||||
|
) -> Response[ModelType]: ...
|
||||||
|
|
||||||
@overload
|
@overload
|
||||||
@classmethod
|
@classmethod
|
||||||
async def get( # pragma: no cover
|
async def get( # pragma: no cover
|
||||||
@@ -376,6 +361,7 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
outer_join: bool = False,
|
outer_join: bool = False,
|
||||||
with_for_update: bool = False,
|
with_for_update: bool = False,
|
||||||
load_options: list[ExecutableOption] | None = None,
|
load_options: list[ExecutableOption] | None = None,
|
||||||
|
as_response: Literal[False] = ...,
|
||||||
schema: None = ...,
|
schema: None = ...,
|
||||||
) -> ModelType: ...
|
) -> ModelType: ...
|
||||||
|
|
||||||
@@ -389,8 +375,9 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
outer_join: bool = False,
|
outer_join: bool = False,
|
||||||
with_for_update: bool = False,
|
with_for_update: bool = False,
|
||||||
load_options: list[ExecutableOption] | None = None,
|
load_options: list[ExecutableOption] | None = None,
|
||||||
|
as_response: bool = False,
|
||||||
schema: type[BaseModel] | None = None,
|
schema: type[BaseModel] | None = None,
|
||||||
) -> ModelType | Response[Any]:
|
) -> ModelType | Response[ModelType] | Response[Any]:
|
||||||
"""Get exactly one record. Raises NotFoundError if not found.
|
"""Get exactly one record. Raises NotFoundError if not found.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -400,18 +387,33 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
outer_join: Use LEFT OUTER JOIN instead of INNER JOIN
|
outer_join: Use LEFT OUTER JOIN instead of INNER JOIN
|
||||||
with_for_update: Lock the row for update
|
with_for_update: Lock the row for update
|
||||||
load_options: SQLAlchemy loader options (e.g., selectinload)
|
load_options: SQLAlchemy loader options (e.g., selectinload)
|
||||||
|
as_response: Deprecated. Use ``schema`` instead. Will be removed in v2.0.
|
||||||
schema: Pydantic schema to serialize the result into. When provided,
|
schema: Pydantic schema to serialize the result into. When provided,
|
||||||
the result is automatically wrapped in a ``Response[schema]``.
|
the result is automatically wrapped in a ``Response[schema]``.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Model instance, or ``Response[schema]`` when ``schema`` is given.
|
Model instance, or ``Response[schema]`` when ``schema`` is given,
|
||||||
|
or ``Response[ModelType]`` when ``as_response=True`` (deprecated).
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
NotFoundError: If no record found
|
NotFoundError: If no record found
|
||||||
MultipleResultsFound: If more than one record found
|
MultipleResultsFound: If more than one record found
|
||||||
"""
|
"""
|
||||||
|
if as_response and schema is None:
|
||||||
|
warnings.warn(
|
||||||
|
"as_response is deprecated and will be removed in v2.0. "
|
||||||
|
"Use schema=YourSchema instead.",
|
||||||
|
DeprecationWarning,
|
||||||
|
stacklevel=2,
|
||||||
|
)
|
||||||
q = select(cls.model)
|
q = select(cls.model)
|
||||||
q = _apply_joins(q, joins, outer_join)
|
if joins:
|
||||||
|
for model, condition in joins:
|
||||||
|
q = (
|
||||||
|
q.outerjoin(model, condition)
|
||||||
|
if outer_join
|
||||||
|
else q.join(model, condition)
|
||||||
|
)
|
||||||
q = q.where(and_(*filters))
|
q = q.where(and_(*filters))
|
||||||
if resolved := cls._resolve_load_options(load_options):
|
if resolved := cls._resolve_load_options(load_options):
|
||||||
q = q.options(*resolved)
|
q = q.options(*resolved)
|
||||||
@@ -422,8 +424,9 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
if not item:
|
if not item:
|
||||||
raise NotFoundError()
|
raise NotFoundError()
|
||||||
result = cast(ModelType, item)
|
result = cast(ModelType, item)
|
||||||
if schema:
|
if as_response or schema:
|
||||||
return Response(data=schema.model_validate(result))
|
data_out = schema.model_validate(result) if schema else result
|
||||||
|
return Response(data=data_out)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -449,7 +452,13 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
Model instance or None
|
Model instance or None
|
||||||
"""
|
"""
|
||||||
q = select(cls.model)
|
q = select(cls.model)
|
||||||
q = _apply_joins(q, joins, outer_join)
|
if joins:
|
||||||
|
for model, condition in joins:
|
||||||
|
q = (
|
||||||
|
q.outerjoin(model, condition)
|
||||||
|
if outer_join
|
||||||
|
else q.join(model, condition)
|
||||||
|
)
|
||||||
if filters:
|
if filters:
|
||||||
q = q.where(and_(*filters))
|
q = q.where(and_(*filters))
|
||||||
if resolved := cls._resolve_load_options(load_options):
|
if resolved := cls._resolve_load_options(load_options):
|
||||||
@@ -486,7 +495,13 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
List of model instances
|
List of model instances
|
||||||
"""
|
"""
|
||||||
q = select(cls.model)
|
q = select(cls.model)
|
||||||
q = _apply_joins(q, joins, outer_join)
|
if joins:
|
||||||
|
for model, condition in joins:
|
||||||
|
q = (
|
||||||
|
q.outerjoin(model, condition)
|
||||||
|
if outer_join
|
||||||
|
else q.join(model, condition)
|
||||||
|
)
|
||||||
if filters:
|
if filters:
|
||||||
q = q.where(and_(*filters))
|
q = q.where(and_(*filters))
|
||||||
if resolved := cls._resolve_load_options(load_options):
|
if resolved := cls._resolve_load_options(load_options):
|
||||||
@@ -511,8 +526,24 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
exclude_unset: bool = True,
|
exclude_unset: bool = True,
|
||||||
exclude_none: bool = False,
|
exclude_none: bool = False,
|
||||||
schema: type[SchemaType],
|
schema: type[SchemaType],
|
||||||
|
as_response: bool = ...,
|
||||||
) -> Response[SchemaType]: ...
|
) -> Response[SchemaType]: ...
|
||||||
|
|
||||||
|
# Backward-compatible - will be removed in v2.0
|
||||||
|
@overload
|
||||||
|
@classmethod
|
||||||
|
async def update( # pragma: no cover
|
||||||
|
cls: type[Self],
|
||||||
|
session: AsyncSession,
|
||||||
|
obj: BaseModel,
|
||||||
|
filters: list[Any],
|
||||||
|
*,
|
||||||
|
exclude_unset: bool = True,
|
||||||
|
exclude_none: bool = False,
|
||||||
|
as_response: Literal[True],
|
||||||
|
schema: None = ...,
|
||||||
|
) -> Response[ModelType]: ...
|
||||||
|
|
||||||
@overload
|
@overload
|
||||||
@classmethod
|
@classmethod
|
||||||
async def update( # pragma: no cover
|
async def update( # pragma: no cover
|
||||||
@@ -523,6 +554,7 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
*,
|
*,
|
||||||
exclude_unset: bool = True,
|
exclude_unset: bool = True,
|
||||||
exclude_none: bool = False,
|
exclude_none: bool = False,
|
||||||
|
as_response: Literal[False] = ...,
|
||||||
schema: None = ...,
|
schema: None = ...,
|
||||||
) -> ModelType: ...
|
) -> ModelType: ...
|
||||||
|
|
||||||
@@ -535,8 +567,9 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
*,
|
*,
|
||||||
exclude_unset: bool = True,
|
exclude_unset: bool = True,
|
||||||
exclude_none: bool = False,
|
exclude_none: bool = False,
|
||||||
|
as_response: bool = False,
|
||||||
schema: type[BaseModel] | None = None,
|
schema: type[BaseModel] | None = None,
|
||||||
) -> ModelType | Response[Any]:
|
) -> ModelType | Response[ModelType] | Response[Any]:
|
||||||
"""Update a record in the database.
|
"""Update a record in the database.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -545,15 +578,24 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
filters: List of SQLAlchemy filter conditions
|
filters: List of SQLAlchemy filter conditions
|
||||||
exclude_unset: Exclude fields not explicitly set in the schema
|
exclude_unset: Exclude fields not explicitly set in the schema
|
||||||
exclude_none: Exclude fields with None value
|
exclude_none: Exclude fields with None value
|
||||||
|
as_response: Deprecated. Use ``schema`` instead. Will be removed in v2.0.
|
||||||
schema: Pydantic schema to serialize the result into. When provided,
|
schema: Pydantic schema to serialize the result into. When provided,
|
||||||
the result is automatically wrapped in a ``Response[schema]``.
|
the result is automatically wrapped in a ``Response[schema]``.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Updated model instance, or ``Response[schema]`` when ``schema`` is given.
|
Updated model instance, or ``Response[schema]`` when ``schema`` is given,
|
||||||
|
or ``Response[ModelType]`` when ``as_response=True`` (deprecated).
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
NotFoundError: If no record found
|
NotFoundError: If no record found
|
||||||
"""
|
"""
|
||||||
|
if as_response and schema is None:
|
||||||
|
warnings.warn(
|
||||||
|
"as_response is deprecated and will be removed in v2.0. "
|
||||||
|
"Use schema=YourSchema instead.",
|
||||||
|
DeprecationWarning,
|
||||||
|
stacklevel=2,
|
||||||
|
)
|
||||||
async with get_transaction(session):
|
async with get_transaction(session):
|
||||||
m2m_exclude = cls._m2m_schema_fields()
|
m2m_exclude = cls._m2m_schema_fields()
|
||||||
|
|
||||||
@@ -583,8 +625,9 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
for rel_attr, related_instances in m2m_resolved.items():
|
for rel_attr, related_instances in m2m_resolved.items():
|
||||||
setattr(db_model, rel_attr, related_instances)
|
setattr(db_model, rel_attr, related_instances)
|
||||||
await session.refresh(db_model)
|
await session.refresh(db_model)
|
||||||
if schema:
|
if as_response or schema:
|
||||||
return Response(data=schema.model_validate(db_model))
|
data_out = schema.model_validate(db_model) if schema else db_model
|
||||||
|
return Response(data=data_out)
|
||||||
return db_model
|
return db_model
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -640,7 +683,7 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
filters: list[Any],
|
filters: list[Any],
|
||||||
*,
|
*,
|
||||||
return_response: Literal[True],
|
as_response: Literal[True],
|
||||||
) -> Response[None]: ...
|
) -> Response[None]: ...
|
||||||
|
|
||||||
@overload
|
@overload
|
||||||
@@ -650,8 +693,8 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
filters: list[Any],
|
filters: list[Any],
|
||||||
*,
|
*,
|
||||||
return_response: Literal[False] = ...,
|
as_response: Literal[False] = ...,
|
||||||
) -> None: ...
|
) -> bool: ...
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def delete(
|
async def delete(
|
||||||
@@ -659,26 +702,33 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
filters: list[Any],
|
filters: list[Any],
|
||||||
*,
|
*,
|
||||||
return_response: bool = False,
|
as_response: bool = False,
|
||||||
) -> None | Response[None]:
|
) -> bool | Response[None]:
|
||||||
"""Delete records from the database.
|
"""Delete records from the database.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
session: DB async session
|
session: DB async session
|
||||||
filters: List of SQLAlchemy filter conditions
|
filters: List of SQLAlchemy filter conditions
|
||||||
return_response: When ``True``, returns ``Response[None]`` instead
|
as_response: Deprecated. Will be removed in v2.0. When ``True``,
|
||||||
of ``None``. Useful for API endpoints that expect a consistent
|
returns ``Response[None]`` instead of ``bool``.
|
||||||
response envelope.
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
``None``, or ``Response[None]`` when ``return_response=True``.
|
``True`` if deletion was executed, or ``Response[None]`` when
|
||||||
|
``as_response=True`` (deprecated).
|
||||||
"""
|
"""
|
||||||
|
if as_response:
|
||||||
|
warnings.warn(
|
||||||
|
"as_response is deprecated and will be removed in v2.0. "
|
||||||
|
"Use schema=YourSchema instead.",
|
||||||
|
DeprecationWarning,
|
||||||
|
stacklevel=2,
|
||||||
|
)
|
||||||
async with get_transaction(session):
|
async with get_transaction(session):
|
||||||
q = sql_delete(cls.model).where(and_(*filters))
|
q = sql_delete(cls.model).where(and_(*filters))
|
||||||
await session.execute(q)
|
await session.execute(q)
|
||||||
if return_response:
|
if as_response:
|
||||||
return Response(data=None)
|
return Response(data=None)
|
||||||
return None
|
return True
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def count(
|
async def count(
|
||||||
@@ -701,7 +751,13 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
Number of matching records
|
Number of matching records
|
||||||
"""
|
"""
|
||||||
q = select(func.count()).select_from(cls.model)
|
q = select(func.count()).select_from(cls.model)
|
||||||
q = _apply_joins(q, joins, outer_join)
|
if joins:
|
||||||
|
for model, condition in joins:
|
||||||
|
q = (
|
||||||
|
q.outerjoin(model, condition)
|
||||||
|
if outer_join
|
||||||
|
else q.join(model, condition)
|
||||||
|
)
|
||||||
if filters:
|
if filters:
|
||||||
q = q.where(and_(*filters))
|
q = q.where(and_(*filters))
|
||||||
result = await session.execute(q)
|
result = await session.execute(q)
|
||||||
@@ -728,11 +784,58 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
True if at least one record matches
|
True if at least one record matches
|
||||||
"""
|
"""
|
||||||
q = select(cls.model)
|
q = select(cls.model)
|
||||||
q = _apply_joins(q, joins, outer_join)
|
if joins:
|
||||||
|
for model, condition in joins:
|
||||||
|
q = (
|
||||||
|
q.outerjoin(model, condition)
|
||||||
|
if outer_join
|
||||||
|
else q.join(model, condition)
|
||||||
|
)
|
||||||
q = q.where(and_(*filters)).exists().select()
|
q = q.where(and_(*filters)).exists().select()
|
||||||
result = await session.execute(q)
|
result = await session.execute(q)
|
||||||
return bool(result.scalar())
|
return bool(result.scalar())
|
||||||
|
|
||||||
|
@overload
|
||||||
|
@classmethod
|
||||||
|
async def offset_paginate( # pragma: no cover
|
||||||
|
cls: type[Self],
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
filters: list[Any] | None = None,
|
||||||
|
joins: JoinType | None = None,
|
||||||
|
outer_join: bool = False,
|
||||||
|
load_options: list[ExecutableOption] | None = None,
|
||||||
|
order_by: OrderByClause | None = None,
|
||||||
|
page: int = 1,
|
||||||
|
items_per_page: int = 20,
|
||||||
|
search: str | SearchConfig | None = None,
|
||||||
|
search_fields: Sequence[SearchFieldType] | None = None,
|
||||||
|
facet_fields: Sequence[FacetFieldType] | None = None,
|
||||||
|
filter_by: dict[str, Any] | BaseModel | None = None,
|
||||||
|
schema: type[SchemaType],
|
||||||
|
) -> PaginatedResponse[SchemaType]: ...
|
||||||
|
|
||||||
|
# Backward-compatible - will be removed in v2.0
|
||||||
|
@overload
|
||||||
|
@classmethod
|
||||||
|
async def offset_paginate( # pragma: no cover
|
||||||
|
cls: type[Self],
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
filters: list[Any] | None = None,
|
||||||
|
joins: JoinType | None = None,
|
||||||
|
outer_join: bool = False,
|
||||||
|
load_options: list[ExecutableOption] | None = None,
|
||||||
|
order_by: OrderByClause | None = None,
|
||||||
|
page: int = 1,
|
||||||
|
items_per_page: int = 20,
|
||||||
|
search: str | SearchConfig | None = None,
|
||||||
|
search_fields: Sequence[SearchFieldType] | None = None,
|
||||||
|
facet_fields: Sequence[FacetFieldType] | None = None,
|
||||||
|
filter_by: dict[str, Any] | BaseModel | None = None,
|
||||||
|
schema: None = ...,
|
||||||
|
) -> PaginatedResponse[ModelType]: ...
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def offset_paginate(
|
async def offset_paginate(
|
||||||
cls: type[Self],
|
cls: type[Self],
|
||||||
@@ -749,8 +852,8 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
search_fields: Sequence[SearchFieldType] | None = None,
|
search_fields: Sequence[SearchFieldType] | None = None,
|
||||||
facet_fields: Sequence[FacetFieldType] | None = None,
|
facet_fields: Sequence[FacetFieldType] | None = None,
|
||||||
filter_by: dict[str, Any] | BaseModel | None = None,
|
filter_by: dict[str, Any] | BaseModel | None = None,
|
||||||
schema: type[BaseModel],
|
schema: type[BaseModel] | None = None,
|
||||||
) -> PaginatedResponse[Any]:
|
) -> PaginatedResponse[ModelType] | PaginatedResponse[Any]:
|
||||||
"""Get paginated results using offset-based pagination.
|
"""Get paginated results using offset-based pagination.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -768,36 +871,54 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
filter_by: Dict of {column_key: value} to filter by declared facet fields.
|
filter_by: Dict of {column_key: value} to filter by declared facet fields.
|
||||||
Keys must match the column.key of a facet field. Scalar → equality,
|
Keys must match the column.key of a facet field. Scalar → equality,
|
||||||
list → IN clause. Raises InvalidFacetFilterError for unknown keys.
|
list → IN clause. Raises InvalidFacetFilterError for unknown keys.
|
||||||
schema: Pydantic schema to serialize each item into.
|
schema: Optional Pydantic schema to serialize each item into.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
PaginatedResponse with OffsetPagination metadata
|
PaginatedResponse with OffsetPagination metadata
|
||||||
"""
|
"""
|
||||||
filters = list(filters) if filters else []
|
filters = list(filters) if filters else []
|
||||||
offset = (page - 1) * items_per_page
|
offset = (page - 1) * items_per_page
|
||||||
|
search_joins: list[Any] = []
|
||||||
|
|
||||||
fb_filters, search_joins = cls._prepare_filter_by(filter_by, facet_fields)
|
if isinstance(filter_by, BaseModel):
|
||||||
|
filter_by = filter_by.model_dump(exclude_none=True) or None
|
||||||
|
|
||||||
|
# Build filter_by conditions from declared facet fields
|
||||||
|
if filter_by:
|
||||||
|
resolved_facets_for_filter = (
|
||||||
|
facet_fields if facet_fields is not None else cls.facet_fields
|
||||||
|
)
|
||||||
|
fb_filters, fb_joins = build_filter_by(
|
||||||
|
filter_by, resolved_facets_for_filter or []
|
||||||
|
)
|
||||||
filters.extend(fb_filters)
|
filters.extend(fb_filters)
|
||||||
|
search_joins.extend(fb_joins)
|
||||||
|
|
||||||
# Build search filters
|
# Build search filters
|
||||||
if search:
|
if search:
|
||||||
search_filters, new_search_joins = build_search_filters(
|
search_filters, search_joins = build_search_filters(
|
||||||
cls.model,
|
cls.model,
|
||||||
search,
|
search,
|
||||||
search_fields=search_fields,
|
search_fields=search_fields,
|
||||||
default_fields=cls.searchable_fields,
|
default_fields=cls.searchable_fields,
|
||||||
)
|
)
|
||||||
filters.extend(search_filters)
|
filters.extend(search_filters)
|
||||||
search_joins.extend(new_search_joins)
|
|
||||||
|
|
||||||
# Build query with joins
|
# Build query with joins
|
||||||
q = select(cls.model)
|
q = select(cls.model)
|
||||||
|
|
||||||
# Apply explicit joins
|
# Apply explicit joins
|
||||||
q = _apply_joins(q, joins, outer_join)
|
if joins:
|
||||||
|
for model, condition in joins:
|
||||||
|
q = (
|
||||||
|
q.outerjoin(model, condition)
|
||||||
|
if outer_join
|
||||||
|
else q.join(model, condition)
|
||||||
|
)
|
||||||
|
|
||||||
# Apply search joins (always outer joins for search)
|
# Apply search joins (always outer joins for search)
|
||||||
q = _apply_search_joins(q, search_joins)
|
for join_rel in search_joins:
|
||||||
|
q = q.outerjoin(join_rel)
|
||||||
|
|
||||||
if filters:
|
if filters:
|
||||||
q = q.where(and_(*filters))
|
q = q.where(and_(*filters))
|
||||||
@@ -809,7 +930,9 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
q = q.offset(offset).limit(items_per_page)
|
q = q.offset(offset).limit(items_per_page)
|
||||||
result = await session.execute(q)
|
result = await session.execute(q)
|
||||||
raw_items = cast(list[ModelType], result.unique().scalars().all())
|
raw_items = cast(list[ModelType], result.unique().scalars().all())
|
||||||
items: list[Any] = [schema.model_validate(item) for item in raw_items]
|
items: list[Any] = (
|
||||||
|
[schema.model_validate(item) for item in raw_items] if schema else raw_items
|
||||||
|
)
|
||||||
|
|
||||||
# Count query (with same joins and filters)
|
# Count query (with same joins and filters)
|
||||||
pk_col = cls.model.__mapper__.primary_key[0]
|
pk_col = cls.model.__mapper__.primary_key[0]
|
||||||
@@ -817,10 +940,17 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
count_q = count_q.select_from(cls.model)
|
count_q = count_q.select_from(cls.model)
|
||||||
|
|
||||||
# Apply explicit joins to count query
|
# Apply explicit joins to count query
|
||||||
count_q = _apply_joins(count_q, joins, outer_join)
|
if joins:
|
||||||
|
for model, condition in joins:
|
||||||
|
count_q = (
|
||||||
|
count_q.outerjoin(model, condition)
|
||||||
|
if outer_join
|
||||||
|
else count_q.join(model, condition)
|
||||||
|
)
|
||||||
|
|
||||||
# Apply search joins to count query
|
# Apply search joins to count query
|
||||||
count_q = _apply_search_joins(count_q, search_joins)
|
for join_rel in search_joins:
|
||||||
|
count_q = count_q.outerjoin(join_rel)
|
||||||
|
|
||||||
if filters:
|
if filters:
|
||||||
count_q = count_q.where(and_(*filters))
|
count_q = count_q.where(and_(*filters))
|
||||||
@@ -828,8 +958,18 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
count_result = await session.execute(count_q)
|
count_result = await session.execute(count_q)
|
||||||
total_count = count_result.scalar_one()
|
total_count = count_result.scalar_one()
|
||||||
|
|
||||||
filter_attributes = await cls._build_filter_attributes(
|
# Build facets
|
||||||
session, facet_fields, filters, search_joins
|
resolved_facet_fields = (
|
||||||
|
facet_fields if facet_fields is not None else cls.facet_fields
|
||||||
|
)
|
||||||
|
filter_attributes: dict[str, list[Any]] | None = None
|
||||||
|
if resolved_facet_fields:
|
||||||
|
filter_attributes = await build_facets(
|
||||||
|
session,
|
||||||
|
cls.model,
|
||||||
|
resolved_facet_fields,
|
||||||
|
base_filters=filters or None,
|
||||||
|
base_joins=search_joins or None,
|
||||||
)
|
)
|
||||||
|
|
||||||
return PaginatedResponse(
|
return PaginatedResponse(
|
||||||
@@ -843,6 +983,50 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
filter_attributes=filter_attributes,
|
filter_attributes=filter_attributes,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Backward-compatible - will be removed in v2.0
|
||||||
|
paginate = offset_paginate
|
||||||
|
|
||||||
|
@overload
|
||||||
|
@classmethod
|
||||||
|
async def cursor_paginate( # pragma: no cover
|
||||||
|
cls: type[Self],
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
cursor: str | None = None,
|
||||||
|
filters: list[Any] | None = None,
|
||||||
|
joins: JoinType | None = None,
|
||||||
|
outer_join: bool = False,
|
||||||
|
load_options: list[ExecutableOption] | None = None,
|
||||||
|
order_by: OrderByClause | None = None,
|
||||||
|
items_per_page: int = 20,
|
||||||
|
search: str | SearchConfig | None = None,
|
||||||
|
search_fields: Sequence[SearchFieldType] | None = None,
|
||||||
|
facet_fields: Sequence[FacetFieldType] | None = None,
|
||||||
|
filter_by: dict[str, Any] | BaseModel | None = None,
|
||||||
|
schema: type[SchemaType],
|
||||||
|
) -> PaginatedResponse[SchemaType]: ...
|
||||||
|
|
||||||
|
# Backward-compatible - will be removed in v2.0
|
||||||
|
@overload
|
||||||
|
@classmethod
|
||||||
|
async def cursor_paginate( # pragma: no cover
|
||||||
|
cls: type[Self],
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
cursor: str | None = None,
|
||||||
|
filters: list[Any] | None = None,
|
||||||
|
joins: JoinType | None = None,
|
||||||
|
outer_join: bool = False,
|
||||||
|
load_options: list[ExecutableOption] | None = None,
|
||||||
|
order_by: OrderByClause | None = None,
|
||||||
|
items_per_page: int = 20,
|
||||||
|
search: str | SearchConfig | None = None,
|
||||||
|
search_fields: Sequence[SearchFieldType] | None = None,
|
||||||
|
facet_fields: Sequence[FacetFieldType] | None = None,
|
||||||
|
filter_by: dict[str, Any] | BaseModel | None = None,
|
||||||
|
schema: None = ...,
|
||||||
|
) -> PaginatedResponse[ModelType]: ...
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def cursor_paginate(
|
async def cursor_paginate(
|
||||||
cls: type[Self],
|
cls: type[Self],
|
||||||
@@ -859,8 +1043,8 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
search_fields: Sequence[SearchFieldType] | None = None,
|
search_fields: Sequence[SearchFieldType] | None = None,
|
||||||
facet_fields: Sequence[FacetFieldType] | None = None,
|
facet_fields: Sequence[FacetFieldType] | None = None,
|
||||||
filter_by: dict[str, Any] | BaseModel | None = None,
|
filter_by: dict[str, Any] | BaseModel | None = None,
|
||||||
schema: type[BaseModel],
|
schema: type[BaseModel] | None = None,
|
||||||
) -> PaginatedResponse[Any]:
|
) -> PaginatedResponse[ModelType] | PaginatedResponse[Any]:
|
||||||
"""Get paginated results using cursor-based pagination.
|
"""Get paginated results using cursor-based pagination.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -887,9 +1071,21 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
PaginatedResponse with CursorPagination metadata
|
PaginatedResponse with CursorPagination metadata
|
||||||
"""
|
"""
|
||||||
filters = list(filters) if filters else []
|
filters = list(filters) if filters else []
|
||||||
|
search_joins: list[Any] = []
|
||||||
|
|
||||||
fb_filters, search_joins = cls._prepare_filter_by(filter_by, facet_fields)
|
if isinstance(filter_by, BaseModel):
|
||||||
|
filter_by = filter_by.model_dump(exclude_none=True) or None
|
||||||
|
|
||||||
|
# Build filter_by conditions from declared facet fields
|
||||||
|
if filter_by:
|
||||||
|
resolved_facets_for_filter = (
|
||||||
|
facet_fields if facet_fields is not None else cls.facet_fields
|
||||||
|
)
|
||||||
|
fb_filters, fb_joins = build_filter_by(
|
||||||
|
filter_by, resolved_facets_for_filter or []
|
||||||
|
)
|
||||||
filters.extend(fb_filters)
|
filters.extend(fb_filters)
|
||||||
|
search_joins.extend(fb_joins)
|
||||||
|
|
||||||
if cls.cursor_column is None:
|
if cls.cursor_column is None:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
@@ -922,23 +1118,29 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
|
|
||||||
# Build search filters
|
# Build search filters
|
||||||
if search:
|
if search:
|
||||||
search_filters, new_search_joins = build_search_filters(
|
search_filters, search_joins = build_search_filters(
|
||||||
cls.model,
|
cls.model,
|
||||||
search,
|
search,
|
||||||
search_fields=search_fields,
|
search_fields=search_fields,
|
||||||
default_fields=cls.searchable_fields,
|
default_fields=cls.searchable_fields,
|
||||||
)
|
)
|
||||||
filters.extend(search_filters)
|
filters.extend(search_filters)
|
||||||
search_joins.extend(new_search_joins)
|
|
||||||
|
|
||||||
# Build query
|
# Build query
|
||||||
q = select(cls.model)
|
q = select(cls.model)
|
||||||
|
|
||||||
# Apply explicit joins
|
# Apply explicit joins
|
||||||
q = _apply_joins(q, joins, outer_join)
|
if joins:
|
||||||
|
for model, condition in joins:
|
||||||
|
q = (
|
||||||
|
q.outerjoin(model, condition)
|
||||||
|
if outer_join
|
||||||
|
else q.join(model, condition)
|
||||||
|
)
|
||||||
|
|
||||||
# Apply search joins (always outer joins)
|
# Apply search joins (always outer joins)
|
||||||
q = _apply_search_joins(q, search_joins)
|
for join_rel in search_joins:
|
||||||
|
q = q.outerjoin(join_rel)
|
||||||
|
|
||||||
if filters:
|
if filters:
|
||||||
q = q.where(and_(*filters))
|
q = q.where(and_(*filters))
|
||||||
@@ -968,10 +1170,24 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
if cursor is not None and items_page:
|
if cursor is not None and items_page:
|
||||||
prev_cursor = _encode_cursor(getattr(items_page[0], cursor_col_name))
|
prev_cursor = _encode_cursor(getattr(items_page[0], cursor_col_name))
|
||||||
|
|
||||||
items: list[Any] = [schema.model_validate(item) for item in items_page]
|
items: list[Any] = (
|
||||||
|
[schema.model_validate(item) for item in items_page]
|
||||||
|
if schema
|
||||||
|
else items_page
|
||||||
|
)
|
||||||
|
|
||||||
filter_attributes = await cls._build_filter_attributes(
|
# Build facets
|
||||||
session, facet_fields, filters, search_joins
|
resolved_facet_fields = (
|
||||||
|
facet_fields if facet_fields is not None else cls.facet_fields
|
||||||
|
)
|
||||||
|
filter_attributes: dict[str, list[Any]] | None = None
|
||||||
|
if resolved_facet_fields:
|
||||||
|
filter_attributes = await build_facets(
|
||||||
|
session,
|
||||||
|
cls.model,
|
||||||
|
resolved_facet_fields,
|
||||||
|
base_filters=filters or None,
|
||||||
|
base_joins=search_joins or None,
|
||||||
)
|
)
|
||||||
|
|
||||||
return PaginatedResponse(
|
return PaginatedResponse(
|
||||||
|
|||||||
@@ -1,23 +1,24 @@
|
|||||||
"""Search utilities for AsyncCrud."""
|
"""Search utilities for AsyncCrud."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import functools
|
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
from dataclasses import dataclass, replace
|
from dataclasses import dataclass
|
||||||
from typing import TYPE_CHECKING, Any, Literal
|
from typing import TYPE_CHECKING, Any, Literal
|
||||||
|
|
||||||
from sqlalchemy import String, and_, or_, select
|
from sqlalchemy import String, or_, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import DeclarativeBase
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
from sqlalchemy.orm.attributes import InstrumentedAttribute
|
from sqlalchemy.orm.attributes import InstrumentedAttribute
|
||||||
|
|
||||||
from ..exceptions import InvalidFacetFilterError, NoSearchableFieldsError
|
from ..exceptions import InvalidFacetFilterError, NoSearchableFieldsError
|
||||||
from ..types import FacetFieldType, SearchFieldType
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from sqlalchemy.sql.elements import ColumnElement
|
from sqlalchemy.sql.elements import ColumnElement
|
||||||
|
|
||||||
|
SearchFieldType = InstrumentedAttribute[Any] | tuple[InstrumentedAttribute[Any], ...]
|
||||||
|
FacetFieldType = SearchFieldType
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class SearchConfig:
|
class SearchConfig:
|
||||||
@@ -36,7 +37,6 @@ class SearchConfig:
|
|||||||
match_mode: Literal["any", "all"] = "any"
|
match_mode: Literal["any", "all"] = "any"
|
||||||
|
|
||||||
|
|
||||||
@functools.lru_cache(maxsize=128)
|
|
||||||
def get_searchable_fields(
|
def get_searchable_fields(
|
||||||
model: type[DeclarativeBase],
|
model: type[DeclarativeBase],
|
||||||
*,
|
*,
|
||||||
@@ -101,10 +101,13 @@ def build_search_filters(
|
|||||||
if isinstance(search, str):
|
if isinstance(search, str):
|
||||||
config = SearchConfig(query=search, fields=search_fields)
|
config = SearchConfig(query=search, fields=search_fields)
|
||||||
else:
|
else:
|
||||||
config = (
|
config = search
|
||||||
replace(search, fields=search_fields)
|
if search_fields is not None:
|
||||||
if search_fields is not None
|
config = SearchConfig(
|
||||||
else search
|
query=config.query,
|
||||||
|
fields=search_fields,
|
||||||
|
case_sensitive=config.case_sensitive,
|
||||||
|
match_mode=config.match_mode,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not config.query or not config.query.strip():
|
if not config.query or not config.query.strip():
|
||||||
@@ -224,6 +227,8 @@ async def build_facets(
|
|||||||
q = q.outerjoin(rel)
|
q = q.outerjoin(rel)
|
||||||
|
|
||||||
if base_filters:
|
if base_filters:
|
||||||
|
from sqlalchemy import and_
|
||||||
|
|
||||||
q = q.where(and_(*base_filters))
|
q = q.where(and_(*base_filters))
|
||||||
|
|
||||||
q = q.order_by(column)
|
q = q.order_by(column)
|
||||||
|
|||||||
@@ -1,17 +1,20 @@
|
|||||||
"""Dependency factories for FastAPI routes."""
|
"""Dependency factories for FastAPI routes."""
|
||||||
|
|
||||||
import inspect
|
import inspect
|
||||||
from collections.abc import Callable
|
from collections.abc import AsyncGenerator, Callable
|
||||||
from typing import Any, cast
|
from typing import Any, TypeVar, cast
|
||||||
|
|
||||||
from fastapi import Depends
|
from fastapi import Depends
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
|
||||||
from .crud import CrudFactory
|
from .crud import CrudFactory
|
||||||
from .types import ModelType, SessionDependency
|
|
||||||
|
|
||||||
__all__ = ["BodyDependency", "PathDependency"]
|
__all__ = ["BodyDependency", "PathDependency"]
|
||||||
|
|
||||||
|
ModelType = TypeVar("ModelType", bound=DeclarativeBase)
|
||||||
|
SessionDependency = Callable[[], AsyncGenerator[AsyncSession, None]]
|
||||||
|
|
||||||
|
|
||||||
def PathDependency(
|
def PathDependency(
|
||||||
model: type[ModelType],
|
model: type[ModelType],
|
||||||
|
|||||||
@@ -14,10 +14,6 @@ from fastapi.responses import JSONResponse
|
|||||||
from ..schemas import ErrorResponse, ResponseStatus
|
from ..schemas import ErrorResponse, ResponseStatus
|
||||||
from .exceptions import ApiException
|
from .exceptions import ApiException
|
||||||
|
|
||||||
_VALIDATION_LOCATION_PARAMS: frozenset[str] = frozenset(
|
|
||||||
{"body", "query", "path", "header", "cookie"}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def init_exceptions_handlers(app: FastAPI) -> FastAPI:
|
def init_exceptions_handlers(app: FastAPI) -> FastAPI:
|
||||||
"""Register exception handlers and custom OpenAPI schema on a FastAPI app.
|
"""Register exception handlers and custom OpenAPI schema on a FastAPI app.
|
||||||
@@ -103,7 +99,7 @@ def _format_validation_error(
|
|||||||
|
|
||||||
for error in errors:
|
for error in errors:
|
||||||
locs = error["loc"]
|
locs = error["loc"]
|
||||||
if locs and locs[0] in _VALIDATION_LOCATION_PARAMS:
|
if locs and locs[0] in ("body", "query", "path", "header", "cookie"):
|
||||||
locs = locs[1:]
|
locs = locs[1:]
|
||||||
field_path = ".".join(str(loc) for loc in locs)
|
field_path = ".".join(str(loc) for loc in locs)
|
||||||
formatted_errors.append(
|
formatted_errors.append(
|
||||||
|
|||||||
@@ -1,84 +1,24 @@
|
|||||||
"""Fixture loading utilities for database seeding."""
|
"""Fixture loading utilities for database seeding."""
|
||||||
|
|
||||||
from collections.abc import Callable, Sequence
|
from collections.abc import Callable, Sequence
|
||||||
from typing import Any
|
from typing import Any, TypeVar
|
||||||
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import DeclarativeBase
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
|
||||||
from ..db import get_transaction
|
from ..db import get_transaction
|
||||||
from ..logger import get_logger
|
from ..logger import get_logger
|
||||||
from ..types import ModelType
|
|
||||||
from .enum import LoadStrategy
|
from .enum import LoadStrategy
|
||||||
from .registry import Context, FixtureRegistry
|
from .registry import Context, FixtureRegistry
|
||||||
|
|
||||||
logger = get_logger()
|
logger = get_logger()
|
||||||
|
|
||||||
|
T = TypeVar("T", bound=DeclarativeBase)
|
||||||
async def _load_ordered(
|
|
||||||
session: AsyncSession,
|
|
||||||
registry: FixtureRegistry,
|
|
||||||
ordered_names: list[str],
|
|
||||||
strategy: LoadStrategy,
|
|
||||||
) -> dict[str, list[DeclarativeBase]]:
|
|
||||||
"""Load fixtures in order."""
|
|
||||||
results: dict[str, list[DeclarativeBase]] = {}
|
|
||||||
|
|
||||||
for name in ordered_names:
|
|
||||||
fixture = registry.get(name)
|
|
||||||
instances = list(fixture.func())
|
|
||||||
|
|
||||||
if not instances:
|
|
||||||
results[name] = []
|
|
||||||
continue
|
|
||||||
|
|
||||||
model_name = type(instances[0]).__name__
|
|
||||||
loaded: list[DeclarativeBase] = []
|
|
||||||
|
|
||||||
async with get_transaction(session):
|
|
||||||
for instance in instances:
|
|
||||||
if strategy == LoadStrategy.INSERT:
|
|
||||||
session.add(instance)
|
|
||||||
loaded.append(instance)
|
|
||||||
|
|
||||||
elif strategy == LoadStrategy.MERGE:
|
|
||||||
merged = await session.merge(instance)
|
|
||||||
loaded.append(merged)
|
|
||||||
|
|
||||||
else: # LoadStrategy.SKIP_EXISTING
|
|
||||||
pk = _get_primary_key(instance)
|
|
||||||
if pk is not None:
|
|
||||||
existing = await session.get(type(instance), pk)
|
|
||||||
if existing is None:
|
|
||||||
session.add(instance)
|
|
||||||
loaded.append(instance)
|
|
||||||
else:
|
|
||||||
session.add(instance)
|
|
||||||
loaded.append(instance)
|
|
||||||
|
|
||||||
results[name] = loaded
|
|
||||||
logger.info(f"Loaded fixture '{name}': {len(loaded)} {model_name}(s)")
|
|
||||||
|
|
||||||
return results
|
|
||||||
|
|
||||||
|
|
||||||
def _get_primary_key(instance: DeclarativeBase) -> Any | None:
|
|
||||||
"""Get the primary key value of a model instance."""
|
|
||||||
mapper = instance.__class__.__mapper__
|
|
||||||
pk_cols = mapper.primary_key
|
|
||||||
|
|
||||||
if len(pk_cols) == 1:
|
|
||||||
return getattr(instance, pk_cols[0].name, None)
|
|
||||||
|
|
||||||
pk_values = tuple(getattr(instance, col.name, None) for col in pk_cols)
|
|
||||||
if all(v is not None for v in pk_values):
|
|
||||||
return pk_values
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def get_obj_by_attr(
|
def get_obj_by_attr(
|
||||||
fixtures: Callable[[], Sequence[ModelType]], attr_name: str, value: Any
|
fixtures: Callable[[], Sequence[T]], attr_name: str, value: Any
|
||||||
) -> ModelType:
|
) -> T:
|
||||||
"""Get a SQLAlchemy model instance by matching an attribute value.
|
"""Get a SQLAlchemy model instance by matching an attribute value.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -117,6 +57,13 @@ async def load_fixtures(
|
|||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict mapping fixture names to loaded instances
|
Dict mapping fixture names to loaded instances
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```python
|
||||||
|
# Loads 'roles' first (dependency), then 'users'
|
||||||
|
result = await load_fixtures(session, fixtures, "users")
|
||||||
|
print(result["users"]) # [User(...), ...]
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
ordered = registry.resolve_dependencies(*names)
|
ordered = registry.resolve_dependencies(*names)
|
||||||
return await _load_ordered(session, registry, ordered, strategy)
|
return await _load_ordered(session, registry, ordered, strategy)
|
||||||
@@ -138,6 +85,76 @@ async def load_fixtures_by_context(
|
|||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict mapping fixture names to loaded instances
|
Dict mapping fixture names to loaded instances
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```python
|
||||||
|
# Load base + testing fixtures
|
||||||
|
await load_fixtures_by_context(
|
||||||
|
session, fixtures,
|
||||||
|
Context.BASE, Context.TESTING
|
||||||
|
)
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
ordered = registry.resolve_context_dependencies(*contexts)
|
ordered = registry.resolve_context_dependencies(*contexts)
|
||||||
return await _load_ordered(session, registry, ordered, strategy)
|
return await _load_ordered(session, registry, ordered, strategy)
|
||||||
|
|
||||||
|
|
||||||
|
async def _load_ordered(
|
||||||
|
session: AsyncSession,
|
||||||
|
registry: FixtureRegistry,
|
||||||
|
ordered_names: list[str],
|
||||||
|
strategy: LoadStrategy,
|
||||||
|
) -> dict[str, list[DeclarativeBase]]:
|
||||||
|
"""Load fixtures in order."""
|
||||||
|
results: dict[str, list[DeclarativeBase]] = {}
|
||||||
|
|
||||||
|
for name in ordered_names:
|
||||||
|
fixture = registry.get(name)
|
||||||
|
instances = list(fixture.func())
|
||||||
|
|
||||||
|
if not instances:
|
||||||
|
results[name] = []
|
||||||
|
continue
|
||||||
|
|
||||||
|
model_name = type(instances[0]).__name__
|
||||||
|
loaded: list[DeclarativeBase] = []
|
||||||
|
|
||||||
|
async with get_transaction(session):
|
||||||
|
for instance in instances:
|
||||||
|
if strategy == LoadStrategy.INSERT:
|
||||||
|
session.add(instance)
|
||||||
|
loaded.append(instance)
|
||||||
|
|
||||||
|
elif strategy == LoadStrategy.MERGE:
|
||||||
|
merged = await session.merge(instance)
|
||||||
|
loaded.append(merged)
|
||||||
|
|
||||||
|
elif strategy == LoadStrategy.SKIP_EXISTING:
|
||||||
|
pk = _get_primary_key(instance)
|
||||||
|
if pk is not None:
|
||||||
|
existing = await session.get(type(instance), pk)
|
||||||
|
if existing is None:
|
||||||
|
session.add(instance)
|
||||||
|
loaded.append(instance)
|
||||||
|
else:
|
||||||
|
session.add(instance)
|
||||||
|
loaded.append(instance)
|
||||||
|
|
||||||
|
results[name] = loaded
|
||||||
|
logger.info(f"Loaded fixture '{name}': {len(loaded)} {model_name}(s)")
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def _get_primary_key(instance: DeclarativeBase) -> Any | None:
|
||||||
|
"""Get the primary key value of a model instance."""
|
||||||
|
mapper = instance.__class__.__mapper__
|
||||||
|
pk_cols = mapper.primary_key
|
||||||
|
|
||||||
|
if len(pk_cols) == 1:
|
||||||
|
return getattr(instance, pk_cols[0].name, None)
|
||||||
|
|
||||||
|
pk_values = tuple(getattr(instance, col.name, None) for col in pk_cols)
|
||||||
|
if all(v is not None for v in pk_values):
|
||||||
|
return pk_values
|
||||||
|
return None
|
||||||
|
|||||||
@@ -53,23 +53,17 @@ def init_metrics(
|
|||||||
logger.debug("Initialising metric provider '%s'", provider.name)
|
logger.debug("Initialising metric provider '%s'", provider.name)
|
||||||
provider.func()
|
provider.func()
|
||||||
|
|
||||||
# Partition collectors and cache env check at startup — both are stable for the app lifetime.
|
collectors = registry.get_collectors()
|
||||||
async_collectors = [
|
|
||||||
c for c in registry.get_collectors() if asyncio.iscoroutinefunction(c.func)
|
|
||||||
]
|
|
||||||
sync_collectors = [
|
|
||||||
c for c in registry.get_collectors() if not asyncio.iscoroutinefunction(c.func)
|
|
||||||
]
|
|
||||||
multiprocess_mode = _is_multiprocess()
|
|
||||||
|
|
||||||
@app.get(path, include_in_schema=False)
|
@app.get(path, include_in_schema=False)
|
||||||
async def metrics_endpoint() -> Response:
|
async def metrics_endpoint() -> Response:
|
||||||
for collector in sync_collectors:
|
for collector in collectors:
|
||||||
collector.func()
|
if asyncio.iscoroutinefunction(collector.func):
|
||||||
for collector in async_collectors:
|
|
||||||
await collector.func()
|
await collector.func()
|
||||||
|
else:
|
||||||
|
collector.func()
|
||||||
|
|
||||||
if multiprocess_mode:
|
if _is_multiprocess():
|
||||||
prom_registry = CollectorRegistry()
|
prom_registry = CollectorRegistry()
|
||||||
multiprocess.MultiProcessCollector(prom_registry)
|
multiprocess.MultiProcessCollector(prom_registry)
|
||||||
output = generate_latest(prom_registry)
|
output = generate_latest(prom_registry)
|
||||||
|
|||||||
@@ -1,23 +1,24 @@
|
|||||||
"""Base Pydantic schemas for API responses."""
|
"""Base Pydantic schemas for API responses."""
|
||||||
|
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Any, ClassVar, Generic
|
from typing import Any, ClassVar, Generic, TypeVar
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
from .types import DataT
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"ApiError",
|
"ApiError",
|
||||||
"CursorPagination",
|
"CursorPagination",
|
||||||
"ErrorResponse",
|
"ErrorResponse",
|
||||||
"OffsetPagination",
|
"OffsetPagination",
|
||||||
|
"Pagination",
|
||||||
"PaginatedResponse",
|
"PaginatedResponse",
|
||||||
"PydanticBase",
|
"PydanticBase",
|
||||||
"Response",
|
"Response",
|
||||||
"ResponseStatus",
|
"ResponseStatus",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
DataT = TypeVar("DataT")
|
||||||
|
|
||||||
|
|
||||||
class PydanticBase(BaseModel):
|
class PydanticBase(BaseModel):
|
||||||
"""Base class for all Pydantic models with common configuration."""
|
"""Base class for all Pydantic models with common configuration."""
|
||||||
@@ -107,6 +108,10 @@ class OffsetPagination(PydanticBase):
|
|||||||
has_more: bool
|
has_more: bool
|
||||||
|
|
||||||
|
|
||||||
|
# Backward-compatible - will be removed in v2.0
|
||||||
|
Pagination = OffsetPagination
|
||||||
|
|
||||||
|
|
||||||
class CursorPagination(PydanticBase):
|
class CursorPagination(PydanticBase):
|
||||||
"""Pagination metadata for cursor-based list responses.
|
"""Pagination metadata for cursor-based list responses.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
"""Authentication helpers for FastAPI using Security()."""
|
||||||
|
|
||||||
|
from .base import AuthSource
|
||||||
|
from .multi import MultiAuth
|
||||||
|
from .sources import BearerTokenAuth, CookieAuth, OAuth2Auth, OpenIDAuth
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"AuthSource",
|
||||||
|
"BearerTokenAuth",
|
||||||
|
"CookieAuth",
|
||||||
|
"OAuth2Auth",
|
||||||
|
"OpenIDAuth",
|
||||||
|
"MultiAuth",
|
||||||
|
]
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
"""Abstract base class for authentication sources."""
|
||||||
|
|
||||||
|
import inspect
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
from fastapi import Request
|
||||||
|
from fastapi.security import SecurityScopes
|
||||||
|
|
||||||
|
from fastapi_toolsets.exceptions import UnauthorizedError
|
||||||
|
|
||||||
|
|
||||||
|
class AuthSource(ABC):
|
||||||
|
"""Abstract base class for authentication sources.
|
||||||
|
|
||||||
|
Subclass this to create a custom auth source that works with
|
||||||
|
:func:`~fastapi_toolsets.security.MultiAuth` and can be used directly
|
||||||
|
with :func:`fastapi.Security`.
|
||||||
|
|
||||||
|
Concrete subclasses must implement :meth:`extract` and
|
||||||
|
:meth:`authenticate`. The default :meth:`__call__` (set up in
|
||||||
|
:meth:`__init__`) wires them together for FastAPI dependency injection.
|
||||||
|
|
||||||
|
Custom subclasses with their own ``__init__`` **must** call
|
||||||
|
``super().__init__()`` to activate the default dependency behaviour::
|
||||||
|
|
||||||
|
class JWTAuth(AuthSource):
|
||||||
|
def __init__(self, secret: str, *, role: str | None = None):
|
||||||
|
super().__init__() # required
|
||||||
|
self._secret = secret
|
||||||
|
self._role = role
|
||||||
|
|
||||||
|
async def extract(self, request: Request) -> str | None:
|
||||||
|
auth = request.headers.get("Authorization", "")
|
||||||
|
if not auth.startswith("Bearer "):
|
||||||
|
return None
|
||||||
|
return auth[7:] or None
|
||||||
|
|
||||||
|
async def authenticate(self, credential: str) -> User:
|
||||||
|
payload = jwt.decode(credential, self._secret)
|
||||||
|
if self._role and payload.get("role") != self._role:
|
||||||
|
raise UnauthorizedError()
|
||||||
|
return User(**payload)
|
||||||
|
|
||||||
|
jwt_auth = JWTAuth(secret="mysecret")
|
||||||
|
|
||||||
|
@app.get("/me")
|
||||||
|
async def me(user: User = Security(jwt_auth)):
|
||||||
|
return user
|
||||||
|
|
||||||
|
# Works with MultiAuth too
|
||||||
|
multi = MultiAuth(jwt_auth, CookieAuth("session", verify_session))
|
||||||
|
|
||||||
|
.. note::
|
||||||
|
The default ``__call__`` does not register a security scheme in the
|
||||||
|
OpenAPI spec. Built-in sources (``BearerTokenAuth`` etc.) override
|
||||||
|
``__call__`` using the ``__signature__`` trick to provide a FastAPI
|
||||||
|
security scheme for Swagger UI.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
"""Set up the default FastAPI dependency signature.
|
||||||
|
|
||||||
|
Creates a closure that FastAPI can introspect to inject
|
||||||
|
:class:`fastapi.Request` and :class:`fastapi.security.SecurityScopes`.
|
||||||
|
The :meth:`__signature__` attribute is set so that ``inspect.signature``
|
||||||
|
(which FastAPI uses internally) returns the correct parameter list.
|
||||||
|
|
||||||
|
Subclasses with their own ``__init__`` must call ``super().__init__()``.
|
||||||
|
Built-in subclasses (``BearerTokenAuth`` etc.) skip this and set up
|
||||||
|
their own ``_call_fn`` / ``__signature__`` directly.
|
||||||
|
"""
|
||||||
|
source = self
|
||||||
|
|
||||||
|
async def _call(
|
||||||
|
request: Request,
|
||||||
|
security_scopes: SecurityScopes, # noqa: ARG001
|
||||||
|
) -> Any:
|
||||||
|
credential = await source.extract(request)
|
||||||
|
if credential is None:
|
||||||
|
raise UnauthorizedError()
|
||||||
|
return await source.authenticate(credential)
|
||||||
|
|
||||||
|
self._call_fn: Callable[..., Any] = _call
|
||||||
|
self.__signature__ = inspect.signature(_call)
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def extract(self, request: Request) -> str | None:
|
||||||
|
"""Extract the raw credential from the request without validating.
|
||||||
|
|
||||||
|
Returns ``None`` if no credential is present for this source.
|
||||||
|
This method must be fast and free of I/O — it is called by
|
||||||
|
:func:`~fastapi_toolsets.security.MultiAuth` for every source on
|
||||||
|
every request.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def authenticate(self, credential: str) -> Any:
|
||||||
|
"""Validate a credential and return the authenticated identity.
|
||||||
|
|
||||||
|
Should raise :class:`~fastapi_toolsets.exceptions.UnauthorizedError`
|
||||||
|
(or any exception) when the credential is invalid. The return value
|
||||||
|
is injected into the route handler as the dependency value.
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def __call__(self, **kwargs: Any) -> Any:
|
||||||
|
"""FastAPI dependency dispatch.
|
||||||
|
|
||||||
|
Delegates to the closure stored in ``_call_fn``, whose signature
|
||||||
|
(stored in ``__signature__``) tells FastAPI which parameters to inject.
|
||||||
|
"""
|
||||||
|
return await self._call_fn(**kwargs)
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
"""MultiAuth: combine multiple authentication sources into a single callable."""
|
||||||
|
|
||||||
|
import inspect
|
||||||
|
from typing import Any, cast
|
||||||
|
|
||||||
|
from fastapi import Request
|
||||||
|
from fastapi.security import SecurityScopes
|
||||||
|
|
||||||
|
from fastapi_toolsets.exceptions import UnauthorizedError
|
||||||
|
|
||||||
|
from .base import AuthSource
|
||||||
|
|
||||||
|
|
||||||
|
class MultiAuth:
|
||||||
|
"""Combine multiple authentication sources into a single callable.
|
||||||
|
|
||||||
|
Sources are tried in order; the first one whose
|
||||||
|
:meth:`~AuthSource.extract` returns a non-``None`` credential wins.
|
||||||
|
Its :meth:`~AuthSource.authenticate` is called and the result returned.
|
||||||
|
|
||||||
|
If a credential is found but the validator raises, the exception propagates
|
||||||
|
immediately — the remaining sources are **not** tried. This prevents
|
||||||
|
silent fallthrough on invalid credentials.
|
||||||
|
|
||||||
|
If no source provides a credential,
|
||||||
|
:class:`~fastapi_toolsets.exceptions.UnauthorizedError` is raised.
|
||||||
|
|
||||||
|
The :meth:`~AuthSource.extract` method of each source performs only
|
||||||
|
string matching (no I/O), so prefix-based dispatch is essentially free.
|
||||||
|
|
||||||
|
Any :class:`~AuthSource` subclass — including user-defined ones — can be
|
||||||
|
passed as a source.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
*sources: Auth source instances to try in order.
|
||||||
|
|
||||||
|
Example::
|
||||||
|
|
||||||
|
user_bearer = BearerTokenAuth(verify_user, prefix="user_")
|
||||||
|
org_bearer = BearerTokenAuth(verify_org, prefix="org_")
|
||||||
|
cookie = CookieAuth("session", verify_session)
|
||||||
|
|
||||||
|
multi = MultiAuth(user_bearer, org_bearer, cookie)
|
||||||
|
|
||||||
|
@app.get("/data")
|
||||||
|
async def data_route(user = Security(multi)):
|
||||||
|
return user
|
||||||
|
|
||||||
|
# Apply a shared requirement to all sources at once
|
||||||
|
@app.get("/admin")
|
||||||
|
async def admin_route(user = Security(multi.require(role=Role.ADMIN))):
|
||||||
|
return user
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, *sources: AuthSource) -> None:
|
||||||
|
self._sources = sources
|
||||||
|
|
||||||
|
_sources = sources
|
||||||
|
|
||||||
|
async def _call(
|
||||||
|
request: Request,
|
||||||
|
security_scopes: SecurityScopes, # noqa: ARG001
|
||||||
|
) -> Any:
|
||||||
|
for source in _sources:
|
||||||
|
credential = await source.extract(request)
|
||||||
|
if credential is not None:
|
||||||
|
return await source.authenticate(credential)
|
||||||
|
raise UnauthorizedError()
|
||||||
|
|
||||||
|
self._call_fn = _call
|
||||||
|
self.__signature__ = inspect.signature(_call)
|
||||||
|
|
||||||
|
async def __call__(self, **kwargs: Any) -> Any:
|
||||||
|
return await self._call_fn(**kwargs)
|
||||||
|
|
||||||
|
def require(self, **kwargs: Any) -> "MultiAuth":
|
||||||
|
"""Return a new :class:`MultiAuth` with kwargs forwarded to each source.
|
||||||
|
|
||||||
|
Calls ``.require(**kwargs)`` on every source that supports it. Sources
|
||||||
|
that do not implement ``.require()`` (e.g. custom :class:`~AuthSource`
|
||||||
|
subclasses) are passed through unchanged.
|
||||||
|
|
||||||
|
New kwargs are merged over each source's existing kwargs — new values
|
||||||
|
win on conflict::
|
||||||
|
|
||||||
|
multi = MultiAuth(bearer, cookie)
|
||||||
|
|
||||||
|
@app.get("/admin")
|
||||||
|
async def admin(user = Security(multi.require(role=Role.ADMIN))):
|
||||||
|
return user
|
||||||
|
"""
|
||||||
|
new_sources = tuple(
|
||||||
|
cast(Any, source).require(**kwargs)
|
||||||
|
if hasattr(source, "require")
|
||||||
|
else source
|
||||||
|
for source in self._sources
|
||||||
|
)
|
||||||
|
return MultiAuth(*new_sources)
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
"""Built-in authentication source implementations."""
|
||||||
|
|
||||||
|
from .bearer import BearerTokenAuth
|
||||||
|
from .cookie import CookieAuth
|
||||||
|
from .oauth2 import OAuth2Auth
|
||||||
|
from .openid import OpenIDAuth
|
||||||
|
|
||||||
|
__all__ = ["BearerTokenAuth", "CookieAuth", "OAuth2Auth", "OpenIDAuth"]
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
"""Bearer token authentication source."""
|
||||||
|
|
||||||
|
import inspect
|
||||||
|
import secrets
|
||||||
|
from typing import Annotated, Any, Callable
|
||||||
|
|
||||||
|
from fastapi import Depends
|
||||||
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer, SecurityScopes
|
||||||
|
|
||||||
|
from fastapi_toolsets.exceptions import UnauthorizedError
|
||||||
|
|
||||||
|
from ..base import AuthSource
|
||||||
|
|
||||||
|
|
||||||
|
class BearerTokenAuth(AuthSource):
|
||||||
|
"""Bearer token authentication source.
|
||||||
|
|
||||||
|
Wraps :class:`fastapi.security.HTTPBearer` for OpenAPI documentation.
|
||||||
|
The validator is called as ``await validator(credential, **kwargs)``
|
||||||
|
where ``kwargs`` are the extra keyword arguments provided at instantiation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
validator: Async callable that receives the credential and any extra
|
||||||
|
keyword arguments, and returns the authenticated identity (e.g. a
|
||||||
|
``User`` model). Should raise
|
||||||
|
:class:`~fastapi_toolsets.exceptions.UnauthorizedError` on failure.
|
||||||
|
prefix: Optional token prefix (e.g. ``"user_"``). If set, only tokens
|
||||||
|
whose value starts with this prefix are matched. The prefix is
|
||||||
|
**kept** in the value passed to the validator — store and compare
|
||||||
|
tokens with their prefix included. Use :meth:`generate_token` to
|
||||||
|
create correctly-prefixed tokens. This enables multiple
|
||||||
|
``BearerTokenAuth`` instances in the same app (e.g. ``"user_"``
|
||||||
|
for user tokens, ``"org_"`` for org tokens).
|
||||||
|
**kwargs: Extra keyword arguments forwarded to the validator on every
|
||||||
|
call (e.g. ``role=Role.ADMIN``).
|
||||||
|
|
||||||
|
Example::
|
||||||
|
|
||||||
|
async def verify_token(token: str, *, role: Role) -> User:
|
||||||
|
user = await db.get_by_token(token) # token includes prefix
|
||||||
|
if not user or user.role != role:
|
||||||
|
raise UnauthorizedError()
|
||||||
|
return user
|
||||||
|
|
||||||
|
bearer_admin = BearerTokenAuth(verify_token, prefix="user_", role=Role.ADMIN)
|
||||||
|
|
||||||
|
# Generate a token to store in DB and return to the client:
|
||||||
|
token = bearer_admin.generate_token() # e.g. "user_Xk3..."
|
||||||
|
|
||||||
|
@app.get("/admin")
|
||||||
|
async def admin_route(user: User = Security(bearer_admin)):
|
||||||
|
return user
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
validator: Callable[..., Any],
|
||||||
|
*,
|
||||||
|
prefix: str | None = None,
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> None:
|
||||||
|
self._validator = validator
|
||||||
|
self._prefix = prefix
|
||||||
|
self._kwargs = kwargs
|
||||||
|
self._scheme = HTTPBearer(auto_error=False)
|
||||||
|
|
||||||
|
# Capture locals for the closure — self._scheme cannot be referenced
|
||||||
|
# inside the Annotated default because annotations are evaluated at
|
||||||
|
# function-definition time (no `from __future__ import annotations`).
|
||||||
|
_scheme = self._scheme
|
||||||
|
_validator = validator
|
||||||
|
_kwargs = kwargs
|
||||||
|
_prefix = prefix
|
||||||
|
|
||||||
|
async def _call(
|
||||||
|
# security_scopes is unused in the body but its presence in the
|
||||||
|
# signature tells FastAPI to aggregate scopes from Security() calls
|
||||||
|
# up the dependency chain and expose them in the OpenAPI schema.
|
||||||
|
security_scopes: SecurityScopes, # noqa: ARG001
|
||||||
|
credentials: Annotated[
|
||||||
|
HTTPAuthorizationCredentials | None, Depends(_scheme)
|
||||||
|
] = None,
|
||||||
|
) -> Any:
|
||||||
|
if credentials is None:
|
||||||
|
raise UnauthorizedError()
|
||||||
|
token = credentials.credentials
|
||||||
|
if _prefix is not None and not token.startswith(_prefix):
|
||||||
|
raise UnauthorizedError()
|
||||||
|
return await _validator(token, **_kwargs)
|
||||||
|
|
||||||
|
# __call__ must be defined on the class (not the instance) so that
|
||||||
|
# callable(self) returns True. We expose the closure's signature via
|
||||||
|
# __signature__ so FastAPI resolves the correct sub-dependencies.
|
||||||
|
self._call_fn = _call
|
||||||
|
self.__signature__ = inspect.signature(_call)
|
||||||
|
|
||||||
|
async def __call__(self, **kwargs: Any) -> Any:
|
||||||
|
return await self._call_fn(**kwargs)
|
||||||
|
|
||||||
|
async def extract(self, request: Any) -> str | None:
|
||||||
|
"""Extract the raw credential from the request without validating.
|
||||||
|
|
||||||
|
Returns ``None`` if no ``Authorization: Bearer`` header is present,
|
||||||
|
the token is empty, or the token does not match the configured prefix.
|
||||||
|
The prefix is included in the returned value.
|
||||||
|
"""
|
||||||
|
auth = request.headers.get("Authorization", "")
|
||||||
|
if not auth.startswith("Bearer "):
|
||||||
|
return None
|
||||||
|
token = auth[7:]
|
||||||
|
if not token:
|
||||||
|
return None
|
||||||
|
if self._prefix is not None and not token.startswith(self._prefix):
|
||||||
|
return None
|
||||||
|
return token
|
||||||
|
|
||||||
|
async def authenticate(self, credential: str) -> Any:
|
||||||
|
"""Validate a credential and return the identity.
|
||||||
|
|
||||||
|
Calls ``await validator(credential, **kwargs)`` where ``kwargs`` are
|
||||||
|
the extra keyword arguments provided at instantiation.
|
||||||
|
"""
|
||||||
|
return await self._validator(credential, **self._kwargs)
|
||||||
|
|
||||||
|
def require(self, **kwargs: Any) -> "BearerTokenAuth":
|
||||||
|
"""Return a new instance with additional (or overriding) validator kwargs.
|
||||||
|
|
||||||
|
Useful for specifying per-endpoint requirements inline without
|
||||||
|
declaring a new top-level variable::
|
||||||
|
|
||||||
|
bearer = BearerTokenAuth(verify_token)
|
||||||
|
|
||||||
|
@app.get("/admin")
|
||||||
|
async def admin(user: User = Security(bearer.require(role=Role.ADMIN))):
|
||||||
|
return user
|
||||||
|
|
||||||
|
The ``prefix`` is preserved. New kwargs are merged over existing ones
|
||||||
|
(new values win on conflict).
|
||||||
|
"""
|
||||||
|
return BearerTokenAuth(
|
||||||
|
self._validator,
|
||||||
|
prefix=self._prefix,
|
||||||
|
**{**self._kwargs, **kwargs},
|
||||||
|
)
|
||||||
|
|
||||||
|
def generate_token(self, nbytes: int = 32) -> str:
|
||||||
|
"""Generate a secure random token for this auth source.
|
||||||
|
|
||||||
|
Returns a URL-safe random token. If a prefix is configured it is
|
||||||
|
prepended — the returned value is what you store in your database
|
||||||
|
and return to the client as-is.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
nbytes: Number of random bytes before base64 encoding. The
|
||||||
|
resulting string is ``ceil(nbytes * 4 / 3)`` characters
|
||||||
|
(43 chars for the default 32 bytes). Defaults to 32.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A ready-to-use token string (e.g. ``"user_Xk3..."``).
|
||||||
|
|
||||||
|
Example::
|
||||||
|
|
||||||
|
bearer = BearerTokenAuth(verify_token, prefix="user_")
|
||||||
|
token = bearer.generate_token() # "user_<random>"
|
||||||
|
await db.store_token(user_id, token)
|
||||||
|
return {"access_token": token, "token_type": "bearer"}
|
||||||
|
"""
|
||||||
|
token = secrets.token_urlsafe(nbytes)
|
||||||
|
if self._prefix is not None:
|
||||||
|
return f"{self._prefix}{token}"
|
||||||
|
return token
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
"""Cookie-based authentication source."""
|
||||||
|
|
||||||
|
import inspect
|
||||||
|
from typing import Annotated, Any, Callable
|
||||||
|
|
||||||
|
from fastapi import Depends, Request
|
||||||
|
from fastapi.security import APIKeyCookie, SecurityScopes
|
||||||
|
|
||||||
|
from fastapi_toolsets.exceptions import UnauthorizedError
|
||||||
|
|
||||||
|
from ..base import AuthSource
|
||||||
|
|
||||||
|
|
||||||
|
class CookieAuth(AuthSource):
|
||||||
|
"""Cookie-based authentication source.
|
||||||
|
|
||||||
|
Wraps :class:`fastapi.security.APIKeyCookie` for OpenAPI documentation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Cookie name to read the credential from.
|
||||||
|
validator: Async callable that receives the cookie value and any extra
|
||||||
|
keyword arguments, and returns the authenticated identity.
|
||||||
|
**kwargs: Extra keyword arguments forwarded to the validator on every
|
||||||
|
call.
|
||||||
|
|
||||||
|
Example::
|
||||||
|
|
||||||
|
async def verify_session(session_id: str) -> User:
|
||||||
|
user = await db.get_by_session(session_id)
|
||||||
|
if not user:
|
||||||
|
raise UnauthorizedError()
|
||||||
|
return user
|
||||||
|
|
||||||
|
cookie_auth = CookieAuth("session", verify_session)
|
||||||
|
|
||||||
|
@app.get("/me")
|
||||||
|
async def me(user: User = Security(cookie_auth)):
|
||||||
|
return user
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
validator: Callable[..., Any],
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> None:
|
||||||
|
self._name = name
|
||||||
|
self._validator = validator
|
||||||
|
self._kwargs = kwargs
|
||||||
|
self._scheme = APIKeyCookie(name=name, auto_error=False)
|
||||||
|
|
||||||
|
_scheme = self._scheme
|
||||||
|
_validator = validator
|
||||||
|
_kwargs = kwargs
|
||||||
|
|
||||||
|
async def _call(
|
||||||
|
security_scopes: SecurityScopes, # noqa: ARG001
|
||||||
|
value: Annotated[str | None, Depends(_scheme)] = None,
|
||||||
|
) -> Any:
|
||||||
|
if value is None:
|
||||||
|
raise UnauthorizedError()
|
||||||
|
return await _validator(value, **_kwargs)
|
||||||
|
|
||||||
|
self._call_fn = _call
|
||||||
|
self.__signature__ = inspect.signature(_call)
|
||||||
|
|
||||||
|
async def __call__(self, **kwargs: Any) -> Any:
|
||||||
|
return await self._call_fn(**kwargs)
|
||||||
|
|
||||||
|
async def extract(self, request: Request) -> str | None:
|
||||||
|
"""Extract the cookie value from the request without validating."""
|
||||||
|
return request.cookies.get(self._name)
|
||||||
|
|
||||||
|
async def authenticate(self, credential: str) -> Any:
|
||||||
|
"""Validate a credential and return the identity."""
|
||||||
|
return await self._validator(credential, **self._kwargs)
|
||||||
|
|
||||||
|
def require(self, **kwargs: Any) -> "CookieAuth":
|
||||||
|
"""Return a new instance with additional (or overriding) validator kwargs.
|
||||||
|
|
||||||
|
The cookie name is preserved. New kwargs are merged over existing ones
|
||||||
|
(new values win on conflict)::
|
||||||
|
|
||||||
|
cookie = CookieAuth("session", verify_session)
|
||||||
|
|
||||||
|
@app.get("/admin")
|
||||||
|
async def admin(user: User = Security(cookie.require(role=Role.ADMIN))):
|
||||||
|
return user
|
||||||
|
"""
|
||||||
|
return CookieAuth(
|
||||||
|
self._name,
|
||||||
|
self._validator,
|
||||||
|
**{**self._kwargs, **kwargs},
|
||||||
|
)
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
"""OAuth2 password-bearer authentication source."""
|
||||||
|
|
||||||
|
import inspect
|
||||||
|
from typing import Annotated, Any, Callable
|
||||||
|
|
||||||
|
from fastapi import Depends, Request
|
||||||
|
from fastapi.security import OAuth2PasswordBearer, SecurityScopes
|
||||||
|
|
||||||
|
from fastapi_toolsets.exceptions import UnauthorizedError
|
||||||
|
|
||||||
|
from ..base import AuthSource
|
||||||
|
|
||||||
|
|
||||||
|
class OAuth2Auth(AuthSource):
|
||||||
|
"""OAuth2 password-bearer authentication source.
|
||||||
|
|
||||||
|
Wraps :class:`fastapi.security.OAuth2PasswordBearer` for OpenAPI
|
||||||
|
documentation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
token_url: URL of the token endpoint (used in OpenAPI docs).
|
||||||
|
validator: Async callable that receives the token and any extra keyword
|
||||||
|
arguments, and returns the authenticated identity.
|
||||||
|
**kwargs: Extra keyword arguments forwarded to the validator on every
|
||||||
|
call.
|
||||||
|
|
||||||
|
Example::
|
||||||
|
|
||||||
|
async def verify_token(token: str) -> User:
|
||||||
|
...
|
||||||
|
|
||||||
|
oauth2_auth = OAuth2Auth(token_url="/token", validator=verify_token)
|
||||||
|
|
||||||
|
@app.get("/me")
|
||||||
|
async def me(user: User = Security(oauth2_auth)):
|
||||||
|
return user
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
token_url: str,
|
||||||
|
validator: Callable[..., Any],
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> None:
|
||||||
|
self._token_url = token_url
|
||||||
|
self._validator = validator
|
||||||
|
self._kwargs = kwargs
|
||||||
|
self._scheme = OAuth2PasswordBearer(tokenUrl=token_url, auto_error=False)
|
||||||
|
|
||||||
|
_scheme = self._scheme
|
||||||
|
_validator = validator
|
||||||
|
_kwargs = kwargs
|
||||||
|
|
||||||
|
async def _call(
|
||||||
|
security_scopes: SecurityScopes, # noqa: ARG001
|
||||||
|
token: Annotated[str | None, Depends(_scheme)] = None,
|
||||||
|
) -> Any:
|
||||||
|
if token is None:
|
||||||
|
raise UnauthorizedError()
|
||||||
|
return await _validator(token, **_kwargs)
|
||||||
|
|
||||||
|
self._call_fn = _call
|
||||||
|
self.__signature__ = inspect.signature(_call)
|
||||||
|
|
||||||
|
async def __call__(self, **kwargs: Any) -> Any:
|
||||||
|
return await self._call_fn(**kwargs)
|
||||||
|
|
||||||
|
async def extract(self, request: Request) -> str | None:
|
||||||
|
"""Extract the bearer token from the Authorization header."""
|
||||||
|
auth = request.headers.get("Authorization", "")
|
||||||
|
if not auth.startswith("Bearer "):
|
||||||
|
return None
|
||||||
|
token = auth[7:]
|
||||||
|
return token or None
|
||||||
|
|
||||||
|
async def authenticate(self, credential: str) -> Any:
|
||||||
|
"""Validate a credential and return the identity."""
|
||||||
|
return await self._validator(credential, **self._kwargs)
|
||||||
|
|
||||||
|
def require(self, **kwargs: Any) -> "OAuth2Auth":
|
||||||
|
"""Return a new instance with additional (or overriding) validator kwargs.
|
||||||
|
|
||||||
|
The token URL is preserved. New kwargs are merged over existing ones
|
||||||
|
(new values win on conflict)::
|
||||||
|
|
||||||
|
oauth2 = OAuth2Auth("/token", verify_token)
|
||||||
|
|
||||||
|
@app.get("/admin")
|
||||||
|
async def admin(user: User = Security(oauth2.require(role=Role.ADMIN))):
|
||||||
|
return user
|
||||||
|
"""
|
||||||
|
return OAuth2Auth(
|
||||||
|
self._token_url,
|
||||||
|
self._validator,
|
||||||
|
**{**self._kwargs, **kwargs},
|
||||||
|
)
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
"""OpenID Connect authentication source."""
|
||||||
|
|
||||||
|
import inspect
|
||||||
|
from typing import Annotated, Any, Callable
|
||||||
|
|
||||||
|
from fastapi import Depends, Request
|
||||||
|
from fastapi.security import OpenIdConnect, SecurityScopes
|
||||||
|
|
||||||
|
from fastapi_toolsets.exceptions import UnauthorizedError
|
||||||
|
|
||||||
|
from ..base import AuthSource
|
||||||
|
|
||||||
|
|
||||||
|
class OpenIDAuth(AuthSource):
|
||||||
|
"""OpenID Connect authentication source.
|
||||||
|
|
||||||
|
Wraps :class:`fastapi.security.OpenIdConnect` for OpenAPI documentation.
|
||||||
|
Token extraction reads the ``Authorization: Bearer <token>`` header;
|
||||||
|
validation is fully delegated to the user-supplied validator (use any
|
||||||
|
OIDC / JWT library such as ``authlib``, ``python-jose``, or ``PyJWT``).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
openid_connect_url: URL of the OIDC discovery document
|
||||||
|
(``/.well-known/openid-configuration``). Used only for OpenAPI
|
||||||
|
documentation — no requests are made to this URL by this class.
|
||||||
|
validator: Async callable that receives the raw bearer token and any
|
||||||
|
extra keyword arguments, and returns the authenticated identity.
|
||||||
|
Should raise :class:`~fastapi_toolsets.exceptions.UnauthorizedError`
|
||||||
|
on failure.
|
||||||
|
**kwargs: Extra keyword arguments forwarded to the validator on every
|
||||||
|
call (e.g. ``audience="my-app"``).
|
||||||
|
|
||||||
|
Example — Google::
|
||||||
|
|
||||||
|
import jwt # e.g. PyJWT or python-jose
|
||||||
|
|
||||||
|
async def verify_google_token(token: str, *, audience: str) -> User:
|
||||||
|
payload = jwt.decode(token, google_public_keys, algorithms=["RS256"],
|
||||||
|
audience=audience)
|
||||||
|
return User(email=payload["email"], name=payload["name"])
|
||||||
|
|
||||||
|
google_auth = OpenIDAuth(
|
||||||
|
"https://accounts.google.com/.well-known/openid-configuration",
|
||||||
|
verify_google_token,
|
||||||
|
audience="my-client-id",
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.get("/me")
|
||||||
|
async def me(user: User = Security(google_auth)):
|
||||||
|
return user
|
||||||
|
|
||||||
|
Multiple providers with :func:`~fastapi_toolsets.security.MultiAuth`::
|
||||||
|
|
||||||
|
multi = MultiAuth(google_auth, github_auth)
|
||||||
|
|
||||||
|
@app.get("/data")
|
||||||
|
async def data(user: User = Security(multi)):
|
||||||
|
return user
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
openid_connect_url: str,
|
||||||
|
validator: Callable[..., Any],
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> None:
|
||||||
|
self._openid_connect_url = openid_connect_url
|
||||||
|
self._validator = validator
|
||||||
|
self._kwargs = kwargs
|
||||||
|
self._scheme = OpenIdConnect(
|
||||||
|
openIdConnectUrl=openid_connect_url, auto_error=False
|
||||||
|
)
|
||||||
|
|
||||||
|
_scheme = self._scheme
|
||||||
|
_validator = validator
|
||||||
|
_kwargs = kwargs
|
||||||
|
|
||||||
|
async def _call(
|
||||||
|
security_scopes: SecurityScopes, # noqa: ARG001
|
||||||
|
# OpenIdConnect (OAuth2 base) returns the full Authorization header
|
||||||
|
# value (e.g. "Bearer <token>"), unlike OAuth2PasswordBearer which
|
||||||
|
# strips the scheme prefix.
|
||||||
|
authorization: Annotated[str | None, Depends(_scheme)] = None,
|
||||||
|
) -> Any:
|
||||||
|
if authorization is None:
|
||||||
|
raise UnauthorizedError()
|
||||||
|
if not authorization.startswith("Bearer "):
|
||||||
|
raise UnauthorizedError()
|
||||||
|
token = authorization[7:]
|
||||||
|
if not token:
|
||||||
|
raise UnauthorizedError()
|
||||||
|
return await _validator(token, **_kwargs)
|
||||||
|
|
||||||
|
self._call_fn = _call
|
||||||
|
self.__signature__ = inspect.signature(_call)
|
||||||
|
|
||||||
|
async def __call__(self, **kwargs: Any) -> Any:
|
||||||
|
return await self._call_fn(**kwargs)
|
||||||
|
|
||||||
|
async def extract(self, request: Request) -> str | None:
|
||||||
|
"""Extract the bearer token from the Authorization header."""
|
||||||
|
auth = request.headers.get("Authorization", "")
|
||||||
|
if not auth.startswith("Bearer "):
|
||||||
|
return None
|
||||||
|
return auth[7:] or None
|
||||||
|
|
||||||
|
async def authenticate(self, credential: str) -> Any:
|
||||||
|
"""Validate a credential and return the identity."""
|
||||||
|
return await self._validator(credential, **self._kwargs)
|
||||||
|
|
||||||
|
def require(self, **kwargs: Any) -> "OpenIDAuth":
|
||||||
|
"""Return a new instance with additional (or overriding) validator kwargs.
|
||||||
|
|
||||||
|
The discovery URL is preserved. New kwargs are merged over existing ones
|
||||||
|
(new values win on conflict)::
|
||||||
|
|
||||||
|
google_auth = OpenIDAuth(discovery_url, verify_google_token, audience="app")
|
||||||
|
|
||||||
|
@app.get("/admin")
|
||||||
|
async def admin(user: User = Security(google_auth.require(role=Role.ADMIN))):
|
||||||
|
return user
|
||||||
|
"""
|
||||||
|
return OpenIDAuth(
|
||||||
|
self._openid_connect_url,
|
||||||
|
self._validator,
|
||||||
|
**{**self._kwargs, **kwargs},
|
||||||
|
)
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
"""Shared type aliases for the fastapi-toolsets package."""
|
|
||||||
|
|
||||||
from collections.abc import AsyncGenerator, Callable, Mapping
|
|
||||||
from typing import Any, TypeVar
|
|
||||||
|
|
||||||
from pydantic import BaseModel
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
from sqlalchemy.orm import DeclarativeBase, QueryableAttribute
|
|
||||||
from sqlalchemy.orm.attributes import InstrumentedAttribute
|
|
||||||
from sqlalchemy.sql.elements import ColumnElement
|
|
||||||
|
|
||||||
# Generic TypeVars
|
|
||||||
DataT = TypeVar("DataT")
|
|
||||||
ModelType = TypeVar("ModelType", bound=DeclarativeBase)
|
|
||||||
SchemaType = TypeVar("SchemaType", bound=BaseModel)
|
|
||||||
|
|
||||||
# CRUD type aliases
|
|
||||||
JoinType = list[tuple[type[DeclarativeBase], Any]]
|
|
||||||
M2MFieldType = Mapping[str, QueryableAttribute[Any]]
|
|
||||||
OrderByClause = ColumnElement[Any] | QueryableAttribute[Any]
|
|
||||||
|
|
||||||
# Search / facet type aliases
|
|
||||||
SearchFieldType = InstrumentedAttribute[Any] | tuple[InstrumentedAttribute[Any], ...]
|
|
||||||
FacetFieldType = SearchFieldType
|
|
||||||
|
|
||||||
# Dependency type aliases
|
|
||||||
SessionDependency = Callable[[], AsyncGenerator[AsyncSession, None]]
|
|
||||||
@@ -92,15 +92,6 @@ class IntRole(Base):
|
|||||||
name: Mapped[str] = mapped_column(String(50), unique=True)
|
name: Mapped[str] = mapped_column(String(50), unique=True)
|
||||||
|
|
||||||
|
|
||||||
class Permission(Base):
|
|
||||||
"""Test model with composite primary key."""
|
|
||||||
|
|
||||||
__tablename__ = "permissions"
|
|
||||||
|
|
||||||
subject: Mapped[str] = mapped_column(String(50), primary_key=True)
|
|
||||||
action: Mapped[str] = mapped_column(String(50), primary_key=True)
|
|
||||||
|
|
||||||
|
|
||||||
class Event(Base):
|
class Event(Base):
|
||||||
"""Test model with DateTime and Date cursor columns."""
|
"""Test model with DateTime and Date cursor columns."""
|
||||||
|
|
||||||
@@ -171,7 +162,6 @@ class UserRead(PydanticBase):
|
|||||||
|
|
||||||
id: uuid.UUID
|
id: uuid.UUID
|
||||||
username: str
|
username: str
|
||||||
is_active: bool = True
|
|
||||||
|
|
||||||
|
|
||||||
class UserUpdate(BaseModel):
|
class UserUpdate(BaseModel):
|
||||||
@@ -228,26 +218,12 @@ class PostM2MUpdate(BaseModel):
|
|||||||
tag_ids: list[uuid.UUID] | None = None
|
tag_ids: list[uuid.UUID] | None = None
|
||||||
|
|
||||||
|
|
||||||
class IntRoleRead(PydanticBase):
|
|
||||||
"""Schema for reading an IntRole."""
|
|
||||||
|
|
||||||
id: int
|
|
||||||
name: str
|
|
||||||
|
|
||||||
|
|
||||||
class IntRoleCreate(BaseModel):
|
class IntRoleCreate(BaseModel):
|
||||||
"""Schema for creating an IntRole."""
|
"""Schema for creating an IntRole."""
|
||||||
|
|
||||||
name: str
|
name: str
|
||||||
|
|
||||||
|
|
||||||
class EventRead(PydanticBase):
|
|
||||||
"""Schema for reading an Event."""
|
|
||||||
|
|
||||||
id: uuid.UUID
|
|
||||||
name: str
|
|
||||||
|
|
||||||
|
|
||||||
class EventCreate(BaseModel):
|
class EventCreate(BaseModel):
|
||||||
"""Schema for creating an Event."""
|
"""Schema for creating an Event."""
|
||||||
|
|
||||||
@@ -256,13 +232,6 @@ class EventCreate(BaseModel):
|
|||||||
scheduled_date: datetime.date
|
scheduled_date: datetime.date
|
||||||
|
|
||||||
|
|
||||||
class ProductRead(PydanticBase):
|
|
||||||
"""Schema for reading a Product."""
|
|
||||||
|
|
||||||
id: uuid.UUID
|
|
||||||
name: str
|
|
||||||
|
|
||||||
|
|
||||||
class ProductCreate(BaseModel):
|
class ProductCreate(BaseModel):
|
||||||
"""Schema for creating a Product."""
|
"""Schema for creating a Product."""
|
||||||
|
|
||||||
|
|||||||
+155
-112
@@ -15,10 +15,8 @@ from .conftest import (
|
|||||||
EventCrud,
|
EventCrud,
|
||||||
EventDateCursorCrud,
|
EventDateCursorCrud,
|
||||||
EventDateTimeCursorCrud,
|
EventDateTimeCursorCrud,
|
||||||
EventRead,
|
|
||||||
IntRoleCreate,
|
IntRoleCreate,
|
||||||
IntRoleCursorCrud,
|
IntRoleCursorCrud,
|
||||||
IntRoleRead,
|
|
||||||
Post,
|
Post,
|
||||||
PostCreate,
|
PostCreate,
|
||||||
PostCrud,
|
PostCrud,
|
||||||
@@ -28,7 +26,6 @@ from .conftest import (
|
|||||||
ProductCreate,
|
ProductCreate,
|
||||||
ProductCrud,
|
ProductCrud,
|
||||||
ProductNumericCursorCrud,
|
ProductNumericCursorCrud,
|
||||||
ProductRead,
|
|
||||||
Role,
|
Role,
|
||||||
RoleCreate,
|
RoleCreate,
|
||||||
RoleCrud,
|
RoleCrud,
|
||||||
@@ -172,14 +169,7 @@ class TestDefaultLoadOptionsIntegration:
|
|||||||
async def test_default_load_options_applied_to_paginate(
|
async def test_default_load_options_applied_to_paginate(
|
||||||
self, db_session: AsyncSession
|
self, db_session: AsyncSession
|
||||||
):
|
):
|
||||||
"""default_load_options loads relationships automatically on offset_paginate()."""
|
"""default_load_options loads relationships automatically on paginate()."""
|
||||||
from fastapi_toolsets.schemas import PydanticBase
|
|
||||||
|
|
||||||
class UserWithRoleRead(PydanticBase):
|
|
||||||
id: uuid.UUID
|
|
||||||
username: str
|
|
||||||
role: RoleRead | None = None
|
|
||||||
|
|
||||||
UserWithDefaultLoad = CrudFactory(
|
UserWithDefaultLoad = CrudFactory(
|
||||||
User, default_load_options=[selectinload(User.role)]
|
User, default_load_options=[selectinload(User.role)]
|
||||||
)
|
)
|
||||||
@@ -188,9 +178,7 @@ class TestDefaultLoadOptionsIntegration:
|
|||||||
db_session,
|
db_session,
|
||||||
UserCreate(username="alice", email="alice@test.com", role_id=role.id),
|
UserCreate(username="alice", email="alice@test.com", role_id=role.id),
|
||||||
)
|
)
|
||||||
result = await UserWithDefaultLoad.offset_paginate(
|
result = await UserWithDefaultLoad.paginate(db_session)
|
||||||
db_session, schema=UserWithRoleRead
|
|
||||||
)
|
|
||||||
assert result.data[0].role is not None
|
assert result.data[0].role is not None
|
||||||
assert result.data[0].role.name == "admin"
|
assert result.data[0].role.name == "admin"
|
||||||
|
|
||||||
@@ -442,7 +430,7 @@ class TestCrudDelete:
|
|||||||
role = await RoleCrud.create(db_session, RoleCreate(name="to_delete"))
|
role = await RoleCrud.create(db_session, RoleCreate(name="to_delete"))
|
||||||
result = await RoleCrud.delete(db_session, [Role.id == role.id])
|
result = await RoleCrud.delete(db_session, [Role.id == role.id])
|
||||||
|
|
||||||
assert result is None
|
assert result is True
|
||||||
assert await RoleCrud.first(db_session, [Role.id == role.id]) is None
|
assert await RoleCrud.first(db_session, [Role.id == role.id]) is None
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
@@ -466,20 +454,6 @@ class TestCrudDelete:
|
|||||||
assert len(remaining) == 1
|
assert len(remaining) == 1
|
||||||
assert remaining[0].username == "u3"
|
assert remaining[0].username == "u3"
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_delete_return_response(self, db_session: AsyncSession):
|
|
||||||
"""Delete with return_response=True returns Response[None]."""
|
|
||||||
from fastapi_toolsets.schemas import Response
|
|
||||||
|
|
||||||
role = await RoleCrud.create(db_session, RoleCreate(name="to_delete_resp"))
|
|
||||||
result = await RoleCrud.delete(
|
|
||||||
db_session, [Role.id == role.id], return_response=True
|
|
||||||
)
|
|
||||||
|
|
||||||
assert isinstance(result, Response)
|
|
||||||
assert result.data is None
|
|
||||||
assert await RoleCrud.first(db_session, [Role.id == role.id]) is None
|
|
||||||
|
|
||||||
|
|
||||||
class TestCrudExists:
|
class TestCrudExists:
|
||||||
"""Tests for CRUD exists operations."""
|
"""Tests for CRUD exists operations."""
|
||||||
@@ -620,9 +594,7 @@ class TestCrudPaginate:
|
|||||||
|
|
||||||
from fastapi_toolsets.schemas import OffsetPagination
|
from fastapi_toolsets.schemas import OffsetPagination
|
||||||
|
|
||||||
result = await RoleCrud.offset_paginate(
|
result = await RoleCrud.paginate(db_session, page=1, items_per_page=10)
|
||||||
db_session, page=1, items_per_page=10, schema=RoleRead
|
|
||||||
)
|
|
||||||
|
|
||||||
assert isinstance(result.pagination, OffsetPagination)
|
assert isinstance(result.pagination, OffsetPagination)
|
||||||
assert len(result.data) == 10
|
assert len(result.data) == 10
|
||||||
@@ -637,9 +609,7 @@ class TestCrudPaginate:
|
|||||||
for i in range(25):
|
for i in range(25):
|
||||||
await RoleCrud.create(db_session, RoleCreate(name=f"role{i:02d}"))
|
await RoleCrud.create(db_session, RoleCreate(name=f"role{i:02d}"))
|
||||||
|
|
||||||
result = await RoleCrud.offset_paginate(
|
result = await RoleCrud.paginate(db_session, page=3, items_per_page=10)
|
||||||
db_session, page=3, items_per_page=10, schema=RoleRead
|
|
||||||
)
|
|
||||||
|
|
||||||
assert len(result.data) == 5
|
assert len(result.data) == 5
|
||||||
assert result.pagination.has_more is False
|
assert result.pagination.has_more is False
|
||||||
@@ -659,12 +629,11 @@ class TestCrudPaginate:
|
|||||||
|
|
||||||
from fastapi_toolsets.schemas import OffsetPagination
|
from fastapi_toolsets.schemas import OffsetPagination
|
||||||
|
|
||||||
result = await UserCrud.offset_paginate(
|
result = await UserCrud.paginate(
|
||||||
db_session,
|
db_session,
|
||||||
filters=[User.is_active == True], # noqa: E712
|
filters=[User.is_active == True], # noqa: E712
|
||||||
page=1,
|
page=1,
|
||||||
items_per_page=10,
|
items_per_page=10,
|
||||||
schema=UserRead,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert isinstance(result.pagination, OffsetPagination)
|
assert isinstance(result.pagination, OffsetPagination)
|
||||||
@@ -677,12 +646,11 @@ class TestCrudPaginate:
|
|||||||
await RoleCrud.create(db_session, RoleCreate(name="alpha"))
|
await RoleCrud.create(db_session, RoleCreate(name="alpha"))
|
||||||
await RoleCrud.create(db_session, RoleCreate(name="bravo"))
|
await RoleCrud.create(db_session, RoleCreate(name="bravo"))
|
||||||
|
|
||||||
result = await RoleCrud.offset_paginate(
|
result = await RoleCrud.paginate(
|
||||||
db_session,
|
db_session,
|
||||||
order_by=Role.name,
|
order_by=Role.name,
|
||||||
page=1,
|
page=1,
|
||||||
items_per_page=10,
|
items_per_page=10,
|
||||||
schema=RoleRead,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
names = [r.name for r in result.data]
|
names = [r.name for r in result.data]
|
||||||
@@ -887,13 +855,12 @@ class TestCrudJoins:
|
|||||||
from fastapi_toolsets.schemas import OffsetPagination
|
from fastapi_toolsets.schemas import OffsetPagination
|
||||||
|
|
||||||
# Paginate users with published posts
|
# Paginate users with published posts
|
||||||
result = await UserCrud.offset_paginate(
|
result = await UserCrud.paginate(
|
||||||
db_session,
|
db_session,
|
||||||
joins=[(Post, Post.author_id == User.id)],
|
joins=[(Post, Post.author_id == User.id)],
|
||||||
filters=[Post.is_published == True], # noqa: E712
|
filters=[Post.is_published == True], # noqa: E712
|
||||||
page=1,
|
page=1,
|
||||||
items_per_page=10,
|
items_per_page=10,
|
||||||
schema=UserRead,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert isinstance(result.pagination, OffsetPagination)
|
assert isinstance(result.pagination, OffsetPagination)
|
||||||
@@ -922,13 +889,12 @@ class TestCrudJoins:
|
|||||||
from fastapi_toolsets.schemas import OffsetPagination
|
from fastapi_toolsets.schemas import OffsetPagination
|
||||||
|
|
||||||
# Paginate with outer join
|
# Paginate with outer join
|
||||||
result = await UserCrud.offset_paginate(
|
result = await UserCrud.paginate(
|
||||||
db_session,
|
db_session,
|
||||||
joins=[(Post, Post.author_id == User.id)],
|
joins=[(Post, Post.author_id == User.id)],
|
||||||
outer_join=True,
|
outer_join=True,
|
||||||
page=1,
|
page=1,
|
||||||
items_per_page=10,
|
items_per_page=10,
|
||||||
schema=UserRead,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert isinstance(result.pagination, OffsetPagination)
|
assert isinstance(result.pagination, OffsetPagination)
|
||||||
@@ -965,6 +931,70 @@ class TestCrudJoins:
|
|||||||
assert users[0].username == "multi_join"
|
assert users[0].username == "multi_join"
|
||||||
|
|
||||||
|
|
||||||
|
class TestAsResponse:
|
||||||
|
"""Tests for as_response parameter (deprecated, kept for backward compat)."""
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_create_as_response(self, db_session: AsyncSession):
|
||||||
|
"""Create with as_response=True returns Response and emits DeprecationWarning."""
|
||||||
|
from fastapi_toolsets.schemas import Response
|
||||||
|
|
||||||
|
data = RoleCreate(name="response_role")
|
||||||
|
with pytest.warns(DeprecationWarning, match="as_response is deprecated"):
|
||||||
|
result = await RoleCrud.create(db_session, data, as_response=True)
|
||||||
|
|
||||||
|
assert isinstance(result, Response)
|
||||||
|
assert result.data is not None
|
||||||
|
assert result.data.name == "response_role"
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_get_as_response(self, db_session: AsyncSession):
|
||||||
|
"""Get with as_response=True returns Response and emits DeprecationWarning."""
|
||||||
|
from fastapi_toolsets.schemas import Response
|
||||||
|
|
||||||
|
created = await RoleCrud.create(db_session, RoleCreate(name="get_response"))
|
||||||
|
with pytest.warns(DeprecationWarning, match="as_response is deprecated"):
|
||||||
|
result = await RoleCrud.get(
|
||||||
|
db_session, [Role.id == created.id], as_response=True
|
||||||
|
)
|
||||||
|
|
||||||
|
assert isinstance(result, Response)
|
||||||
|
assert result.data is not None
|
||||||
|
assert result.data.id == created.id
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_update_as_response(self, db_session: AsyncSession):
|
||||||
|
"""Update with as_response=True returns Response and emits DeprecationWarning."""
|
||||||
|
from fastapi_toolsets.schemas import Response
|
||||||
|
|
||||||
|
created = await RoleCrud.create(db_session, RoleCreate(name="old_name"))
|
||||||
|
with pytest.warns(DeprecationWarning, match="as_response is deprecated"):
|
||||||
|
result = await RoleCrud.update(
|
||||||
|
db_session,
|
||||||
|
RoleUpdate(name="new_name"),
|
||||||
|
[Role.id == created.id],
|
||||||
|
as_response=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert isinstance(result, Response)
|
||||||
|
assert result.data is not None
|
||||||
|
assert result.data.name == "new_name"
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_delete_as_response(self, db_session: AsyncSession):
|
||||||
|
"""Delete with as_response=True returns Response and emits DeprecationWarning."""
|
||||||
|
from fastapi_toolsets.schemas import Response
|
||||||
|
|
||||||
|
created = await RoleCrud.create(db_session, RoleCreate(name="to_delete"))
|
||||||
|
with pytest.warns(DeprecationWarning, match="as_response is deprecated"):
|
||||||
|
result = await RoleCrud.delete(
|
||||||
|
db_session, [Role.id == created.id], as_response=True
|
||||||
|
)
|
||||||
|
|
||||||
|
assert isinstance(result, Response)
|
||||||
|
assert result.data is None
|
||||||
|
|
||||||
|
|
||||||
class TestCrudFactoryM2M:
|
class TestCrudFactoryM2M:
|
||||||
"""Tests for CrudFactory with m2m_fields parameter."""
|
"""Tests for CrudFactory with m2m_fields parameter."""
|
||||||
|
|
||||||
@@ -1445,35 +1475,92 @@ class TestSchemaResponse:
|
|||||||
assert isinstance(result, Response)
|
assert isinstance(result, Response)
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_offset_paginate_with_schema(self, db_session: AsyncSession):
|
async def test_paginate_with_schema(self, db_session: AsyncSession):
|
||||||
"""offset_paginate with schema returns PaginatedResponse[SchemaType]."""
|
"""paginate with schema returns PaginatedResponse[SchemaType]."""
|
||||||
from fastapi_toolsets.schemas import PaginatedResponse
|
from fastapi_toolsets.schemas import PaginatedResponse
|
||||||
|
|
||||||
await RoleCrud.create(db_session, RoleCreate(name="p_role1"))
|
await RoleCrud.create(db_session, RoleCreate(name="p_role1"))
|
||||||
await RoleCrud.create(db_session, RoleCreate(name="p_role2"))
|
await RoleCrud.create(db_session, RoleCreate(name="p_role2"))
|
||||||
|
|
||||||
result = await RoleCrud.offset_paginate(db_session, schema=RoleRead)
|
result = await RoleCrud.paginate(db_session, schema=RoleRead)
|
||||||
|
|
||||||
assert isinstance(result, PaginatedResponse)
|
assert isinstance(result, PaginatedResponse)
|
||||||
assert len(result.data) == 2
|
assert len(result.data) == 2
|
||||||
assert all(isinstance(item, RoleRead) for item in result.data)
|
assert all(isinstance(item, RoleRead) for item in result.data)
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_offset_paginate_schema_filters_fields(
|
async def test_paginate_schema_filters_fields(self, db_session: AsyncSession):
|
||||||
self, db_session: AsyncSession
|
"""paginate with schema only exposes schema fields per item."""
|
||||||
):
|
|
||||||
"""offset_paginate with schema only exposes schema fields per item."""
|
|
||||||
await UserCrud.create(
|
await UserCrud.create(
|
||||||
db_session,
|
db_session,
|
||||||
UserCreate(username="pg_user", email="pg@test.com"),
|
UserCreate(username="pg_user", email="pg@test.com"),
|
||||||
)
|
)
|
||||||
|
|
||||||
result = await UserCrud.offset_paginate(db_session, schema=UserRead)
|
result = await UserCrud.paginate(db_session, schema=UserRead)
|
||||||
|
|
||||||
assert isinstance(result.data[0], UserRead)
|
assert isinstance(result.data[0], UserRead)
|
||||||
assert result.data[0].username == "pg_user"
|
assert result.data[0].username == "pg_user"
|
||||||
assert not hasattr(result.data[0], "email")
|
assert not hasattr(result.data[0], "email")
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_as_response_true_without_schema_unchanged(
|
||||||
|
self, db_session: AsyncSession
|
||||||
|
):
|
||||||
|
"""as_response=True without schema still returns Response[ModelType] with a warning."""
|
||||||
|
from fastapi_toolsets.schemas import Response
|
||||||
|
|
||||||
|
created = await RoleCrud.create(db_session, RoleCreate(name="compat"))
|
||||||
|
with pytest.warns(DeprecationWarning, match="as_response is deprecated"):
|
||||||
|
result = await RoleCrud.get(
|
||||||
|
db_session, [Role.id == created.id], as_response=True
|
||||||
|
)
|
||||||
|
|
||||||
|
assert isinstance(result, Response)
|
||||||
|
assert isinstance(result.data, Role)
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_schema_with_explicit_as_response_true(
|
||||||
|
self, db_session: AsyncSession
|
||||||
|
):
|
||||||
|
"""schema combined with explicit as_response=True works correctly."""
|
||||||
|
from fastapi_toolsets.schemas import Response
|
||||||
|
|
||||||
|
created = await RoleCrud.create(db_session, RoleCreate(name="combined"))
|
||||||
|
result = await RoleCrud.get(
|
||||||
|
db_session,
|
||||||
|
[Role.id == created.id],
|
||||||
|
as_response=True,
|
||||||
|
schema=RoleRead,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert isinstance(result, Response)
|
||||||
|
assert isinstance(result.data, RoleRead)
|
||||||
|
|
||||||
|
|
||||||
|
class TestPaginateAlias:
|
||||||
|
"""Tests that paginate is a backward-compatible alias for offset_paginate."""
|
||||||
|
|
||||||
|
def test_paginate_is_alias_of_offset_paginate(self):
|
||||||
|
"""paginate and offset_paginate are the same underlying function."""
|
||||||
|
assert RoleCrud.paginate.__func__ is RoleCrud.offset_paginate.__func__
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_paginate_alias_returns_offset_pagination(
|
||||||
|
self, db_session: AsyncSession
|
||||||
|
):
|
||||||
|
"""paginate() still works and returns PaginatedResponse with OffsetPagination."""
|
||||||
|
from fastapi_toolsets.schemas import OffsetPagination, PaginatedResponse
|
||||||
|
|
||||||
|
for i in range(3):
|
||||||
|
await RoleCrud.create(db_session, RoleCreate(name=f"role{i:02d}"))
|
||||||
|
|
||||||
|
result = await RoleCrud.paginate(db_session, page=1, items_per_page=10)
|
||||||
|
|
||||||
|
assert isinstance(result, PaginatedResponse)
|
||||||
|
assert isinstance(result.pagination, OffsetPagination)
|
||||||
|
assert result.pagination.total_count == 3
|
||||||
|
assert result.pagination.page == 1
|
||||||
|
|
||||||
|
|
||||||
class TestCursorPaginate:
|
class TestCursorPaginate:
|
||||||
"""Tests for cursor-based pagination via cursor_paginate()."""
|
"""Tests for cursor-based pagination via cursor_paginate()."""
|
||||||
@@ -1486,9 +1573,7 @@ class TestCursorPaginate:
|
|||||||
for i in range(25):
|
for i in range(25):
|
||||||
await RoleCrud.create(db_session, RoleCreate(name=f"role{i:02d}"))
|
await RoleCrud.create(db_session, RoleCreate(name=f"role{i:02d}"))
|
||||||
|
|
||||||
result = await RoleCursorCrud.cursor_paginate(
|
result = await RoleCursorCrud.cursor_paginate(db_session, items_per_page=10)
|
||||||
db_session, items_per_page=10, schema=RoleRead
|
|
||||||
)
|
|
||||||
|
|
||||||
assert isinstance(result, PaginatedResponse)
|
assert isinstance(result, PaginatedResponse)
|
||||||
assert isinstance(result.pagination, CursorPagination)
|
assert isinstance(result.pagination, CursorPagination)
|
||||||
@@ -1506,9 +1591,7 @@ class TestCursorPaginate:
|
|||||||
for i in range(5):
|
for i in range(5):
|
||||||
await RoleCrud.create(db_session, RoleCreate(name=f"role{i:02d}"))
|
await RoleCrud.create(db_session, RoleCreate(name=f"role{i:02d}"))
|
||||||
|
|
||||||
result = await RoleCursorCrud.cursor_paginate(
|
result = await RoleCursorCrud.cursor_paginate(db_session, items_per_page=10)
|
||||||
db_session, items_per_page=10, schema=RoleRead
|
|
||||||
)
|
|
||||||
|
|
||||||
assert isinstance(result.pagination, CursorPagination)
|
assert isinstance(result.pagination, CursorPagination)
|
||||||
assert len(result.data) == 5
|
assert len(result.data) == 5
|
||||||
@@ -1523,16 +1606,14 @@ class TestCursorPaginate:
|
|||||||
|
|
||||||
from fastapi_toolsets.schemas import CursorPagination
|
from fastapi_toolsets.schemas import CursorPagination
|
||||||
|
|
||||||
page1 = await RoleCursorCrud.cursor_paginate(
|
page1 = await RoleCursorCrud.cursor_paginate(db_session, items_per_page=10)
|
||||||
db_session, items_per_page=10, schema=RoleRead
|
|
||||||
)
|
|
||||||
assert isinstance(page1.pagination, CursorPagination)
|
assert isinstance(page1.pagination, CursorPagination)
|
||||||
assert len(page1.data) == 10
|
assert len(page1.data) == 10
|
||||||
assert page1.pagination.has_more is True
|
assert page1.pagination.has_more is True
|
||||||
|
|
||||||
cursor = page1.pagination.next_cursor
|
cursor = page1.pagination.next_cursor
|
||||||
page2 = await RoleCursorCrud.cursor_paginate(
|
page2 = await RoleCursorCrud.cursor_paginate(
|
||||||
db_session, cursor=cursor, items_per_page=10, schema=RoleRead
|
db_session, cursor=cursor, items_per_page=10
|
||||||
)
|
)
|
||||||
assert isinstance(page2.pagination, CursorPagination)
|
assert isinstance(page2.pagination, CursorPagination)
|
||||||
assert len(page2.data) == 5
|
assert len(page2.data) == 5
|
||||||
@@ -1547,15 +1628,12 @@ class TestCursorPaginate:
|
|||||||
|
|
||||||
from fastapi_toolsets.schemas import CursorPagination
|
from fastapi_toolsets.schemas import CursorPagination
|
||||||
|
|
||||||
page1 = await RoleCursorCrud.cursor_paginate(
|
page1 = await RoleCursorCrud.cursor_paginate(db_session, items_per_page=4)
|
||||||
db_session, items_per_page=4, schema=RoleRead
|
|
||||||
)
|
|
||||||
assert isinstance(page1.pagination, CursorPagination)
|
assert isinstance(page1.pagination, CursorPagination)
|
||||||
page2 = await RoleCursorCrud.cursor_paginate(
|
page2 = await RoleCursorCrud.cursor_paginate(
|
||||||
db_session,
|
db_session,
|
||||||
cursor=page1.pagination.next_cursor,
|
cursor=page1.pagination.next_cursor,
|
||||||
items_per_page=4,
|
items_per_page=4,
|
||||||
schema=RoleRead,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
ids_page1 = {r.id for r in page1.data}
|
ids_page1 = {r.id for r in page1.data}
|
||||||
@@ -1568,9 +1646,7 @@ class TestCursorPaginate:
|
|||||||
"""cursor_paginate on an empty table returns empty data with no cursor."""
|
"""cursor_paginate on an empty table returns empty data with no cursor."""
|
||||||
from fastapi_toolsets.schemas import CursorPagination
|
from fastapi_toolsets.schemas import CursorPagination
|
||||||
|
|
||||||
result = await RoleCursorCrud.cursor_paginate(
|
result = await RoleCursorCrud.cursor_paginate(db_session, items_per_page=10)
|
||||||
db_session, items_per_page=10, schema=RoleRead
|
|
||||||
)
|
|
||||||
|
|
||||||
assert isinstance(result.pagination, CursorPagination)
|
assert isinstance(result.pagination, CursorPagination)
|
||||||
assert result.data == []
|
assert result.data == []
|
||||||
@@ -1595,7 +1671,6 @@ class TestCursorPaginate:
|
|||||||
db_session,
|
db_session,
|
||||||
filters=[User.is_active == True], # noqa: E712
|
filters=[User.is_active == True], # noqa: E712
|
||||||
items_per_page=20,
|
items_per_page=20,
|
||||||
schema=UserRead,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert len(result.data) == 5
|
assert len(result.data) == 5
|
||||||
@@ -1628,9 +1703,7 @@ class TestCursorPaginate:
|
|||||||
for i in range(5):
|
for i in range(5):
|
||||||
await RoleNameCrud.create(db_session, RoleCreate(name=f"role{i:02d}"))
|
await RoleNameCrud.create(db_session, RoleCreate(name=f"role{i:02d}"))
|
||||||
|
|
||||||
result = await RoleNameCrud.cursor_paginate(
|
result = await RoleNameCrud.cursor_paginate(db_session, items_per_page=3)
|
||||||
db_session, items_per_page=3, schema=RoleRead
|
|
||||||
)
|
|
||||||
|
|
||||||
assert isinstance(result.pagination, CursorPagination)
|
assert isinstance(result.pagination, CursorPagination)
|
||||||
assert len(result.data) == 3
|
assert len(result.data) == 3
|
||||||
@@ -1641,7 +1714,7 @@ class TestCursorPaginate:
|
|||||||
async def test_raises_without_cursor_column(self, db_session: AsyncSession):
|
async def test_raises_without_cursor_column(self, db_session: AsyncSession):
|
||||||
"""cursor_paginate raises ValueError when cursor_column is not configured."""
|
"""cursor_paginate raises ValueError when cursor_column is not configured."""
|
||||||
with pytest.raises(ValueError, match="cursor_column is not set"):
|
with pytest.raises(ValueError, match="cursor_column is not set"):
|
||||||
await RoleCrud.cursor_paginate(db_session, schema=RoleRead)
|
await RoleCrud.cursor_paginate(db_session)
|
||||||
|
|
||||||
|
|
||||||
class TestCursorPaginatePrevCursor:
|
class TestCursorPaginatePrevCursor:
|
||||||
@@ -1655,9 +1728,7 @@ class TestCursorPaginatePrevCursor:
|
|||||||
|
|
||||||
from fastapi_toolsets.schemas import CursorPagination
|
from fastapi_toolsets.schemas import CursorPagination
|
||||||
|
|
||||||
result = await RoleCursorCrud.cursor_paginate(
|
result = await RoleCursorCrud.cursor_paginate(db_session, items_per_page=3)
|
||||||
db_session, items_per_page=3, schema=RoleRead
|
|
||||||
)
|
|
||||||
|
|
||||||
assert isinstance(result.pagination, CursorPagination)
|
assert isinstance(result.pagination, CursorPagination)
|
||||||
assert result.pagination.prev_cursor is None
|
assert result.pagination.prev_cursor is None
|
||||||
@@ -1670,15 +1741,12 @@ class TestCursorPaginatePrevCursor:
|
|||||||
|
|
||||||
from fastapi_toolsets.schemas import CursorPagination
|
from fastapi_toolsets.schemas import CursorPagination
|
||||||
|
|
||||||
page1 = await RoleCursorCrud.cursor_paginate(
|
page1 = await RoleCursorCrud.cursor_paginate(db_session, items_per_page=5)
|
||||||
db_session, items_per_page=5, schema=RoleRead
|
|
||||||
)
|
|
||||||
assert isinstance(page1.pagination, CursorPagination)
|
assert isinstance(page1.pagination, CursorPagination)
|
||||||
page2 = await RoleCursorCrud.cursor_paginate(
|
page2 = await RoleCursorCrud.cursor_paginate(
|
||||||
db_session,
|
db_session,
|
||||||
cursor=page1.pagination.next_cursor,
|
cursor=page1.pagination.next_cursor,
|
||||||
items_per_page=5,
|
items_per_page=5,
|
||||||
schema=RoleRead,
|
|
||||||
)
|
)
|
||||||
assert isinstance(page2.pagination, CursorPagination)
|
assert isinstance(page2.pagination, CursorPagination)
|
||||||
assert page2.pagination.prev_cursor is not None
|
assert page2.pagination.prev_cursor is not None
|
||||||
@@ -1694,15 +1762,12 @@ class TestCursorPaginatePrevCursor:
|
|||||||
|
|
||||||
from fastapi_toolsets.schemas import CursorPagination
|
from fastapi_toolsets.schemas import CursorPagination
|
||||||
|
|
||||||
page1 = await RoleCursorCrud.cursor_paginate(
|
page1 = await RoleCursorCrud.cursor_paginate(db_session, items_per_page=5)
|
||||||
db_session, items_per_page=5, schema=RoleRead
|
|
||||||
)
|
|
||||||
assert isinstance(page1.pagination, CursorPagination)
|
assert isinstance(page1.pagination, CursorPagination)
|
||||||
page2 = await RoleCursorCrud.cursor_paginate(
|
page2 = await RoleCursorCrud.cursor_paginate(
|
||||||
db_session,
|
db_session,
|
||||||
cursor=page1.pagination.next_cursor,
|
cursor=page1.pagination.next_cursor,
|
||||||
items_per_page=5,
|
items_per_page=5,
|
||||||
schema=RoleRead,
|
|
||||||
)
|
)
|
||||||
assert isinstance(page2.pagination, CursorPagination)
|
assert isinstance(page2.pagination, CursorPagination)
|
||||||
assert page2.pagination.prev_cursor is not None
|
assert page2.pagination.prev_cursor is not None
|
||||||
@@ -1737,7 +1802,6 @@ class TestCursorPaginateWithSearch:
|
|||||||
db_session,
|
db_session,
|
||||||
search="admin",
|
search="admin",
|
||||||
items_per_page=20,
|
items_per_page=20,
|
||||||
schema=RoleRead,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert len(result.data) == 5
|
assert len(result.data) == 5
|
||||||
@@ -1772,7 +1836,6 @@ class TestCursorPaginateExtraOptions:
|
|||||||
db_session,
|
db_session,
|
||||||
joins=[(Role, User.role_id == Role.id)],
|
joins=[(Role, User.role_id == Role.id)],
|
||||||
items_per_page=20,
|
items_per_page=20,
|
||||||
schema=UserRead,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert isinstance(result.pagination, CursorPagination)
|
assert isinstance(result.pagination, CursorPagination)
|
||||||
@@ -1804,7 +1867,6 @@ class TestCursorPaginateExtraOptions:
|
|||||||
joins=[(Role, User.role_id == Role.id)],
|
joins=[(Role, User.role_id == Role.id)],
|
||||||
outer_join=True,
|
outer_join=True,
|
||||||
items_per_page=20,
|
items_per_page=20,
|
||||||
schema=UserRead,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert isinstance(result.pagination, CursorPagination)
|
assert isinstance(result.pagination, CursorPagination)
|
||||||
@@ -1814,12 +1876,7 @@ class TestCursorPaginateExtraOptions:
|
|||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_with_load_options(self, db_session: AsyncSession):
|
async def test_with_load_options(self, db_session: AsyncSession):
|
||||||
"""cursor_paginate passes load_options to the query."""
|
"""cursor_paginate passes load_options to the query."""
|
||||||
from fastapi_toolsets.schemas import CursorPagination, PydanticBase
|
from fastapi_toolsets.schemas import CursorPagination
|
||||||
|
|
||||||
class UserWithRoleRead(PydanticBase):
|
|
||||||
id: uuid.UUID
|
|
||||||
username: str
|
|
||||||
role: RoleRead | None = None
|
|
||||||
|
|
||||||
role = await RoleCrud.create(db_session, RoleCreate(name="manager"))
|
role = await RoleCrud.create(db_session, RoleCreate(name="manager"))
|
||||||
for i in range(3):
|
for i in range(3):
|
||||||
@@ -1836,7 +1893,6 @@ class TestCursorPaginateExtraOptions:
|
|||||||
db_session,
|
db_session,
|
||||||
load_options=[selectinload(User.role)],
|
load_options=[selectinload(User.role)],
|
||||||
items_per_page=20,
|
items_per_page=20,
|
||||||
schema=UserWithRoleRead,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert isinstance(result.pagination, CursorPagination)
|
assert isinstance(result.pagination, CursorPagination)
|
||||||
@@ -1856,7 +1912,6 @@ class TestCursorPaginateExtraOptions:
|
|||||||
db_session,
|
db_session,
|
||||||
order_by=Role.name.desc(),
|
order_by=Role.name.desc(),
|
||||||
items_per_page=3,
|
items_per_page=3,
|
||||||
schema=RoleRead,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert isinstance(result.pagination, CursorPagination)
|
assert isinstance(result.pagination, CursorPagination)
|
||||||
@@ -1870,9 +1925,7 @@ class TestCursorPaginateExtraOptions:
|
|||||||
for i in range(5):
|
for i in range(5):
|
||||||
await IntRoleCursorCrud.create(db_session, IntRoleCreate(name=f"role{i}"))
|
await IntRoleCursorCrud.create(db_session, IntRoleCreate(name=f"role{i}"))
|
||||||
|
|
||||||
page1 = await IntRoleCursorCrud.cursor_paginate(
|
page1 = await IntRoleCursorCrud.cursor_paginate(db_session, items_per_page=3)
|
||||||
db_session, items_per_page=3, schema=IntRoleRead
|
|
||||||
)
|
|
||||||
|
|
||||||
assert isinstance(page1.pagination, CursorPagination)
|
assert isinstance(page1.pagination, CursorPagination)
|
||||||
assert len(page1.data) == 3
|
assert len(page1.data) == 3
|
||||||
@@ -1882,7 +1935,6 @@ class TestCursorPaginateExtraOptions:
|
|||||||
db_session,
|
db_session,
|
||||||
cursor=page1.pagination.next_cursor,
|
cursor=page1.pagination.next_cursor,
|
||||||
items_per_page=3,
|
items_per_page=3,
|
||||||
schema=IntRoleRead,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert isinstance(page2.pagination, CursorPagination)
|
assert isinstance(page2.pagination, CursorPagination)
|
||||||
@@ -1903,9 +1955,7 @@ class TestCursorPaginateExtraOptions:
|
|||||||
await RoleCrud.create(db_session, RoleCreate(name="role01"))
|
await RoleCrud.create(db_session, RoleCreate(name="role01"))
|
||||||
|
|
||||||
# First page succeeds (no cursor to decode)
|
# First page succeeds (no cursor to decode)
|
||||||
page1 = await RoleNameCursorCrud.cursor_paginate(
|
page1 = await RoleNameCursorCrud.cursor_paginate(db_session, items_per_page=1)
|
||||||
db_session, items_per_page=1, schema=RoleRead
|
|
||||||
)
|
|
||||||
assert page1.pagination.has_more is True
|
assert page1.pagination.has_more is True
|
||||||
assert isinstance(page1.pagination, CursorPagination)
|
assert isinstance(page1.pagination, CursorPagination)
|
||||||
|
|
||||||
@@ -1915,7 +1965,6 @@ class TestCursorPaginateExtraOptions:
|
|||||||
db_session,
|
db_session,
|
||||||
cursor=page1.pagination.next_cursor,
|
cursor=page1.pagination.next_cursor,
|
||||||
items_per_page=1,
|
items_per_page=1,
|
||||||
schema=RoleRead,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -1954,7 +2003,6 @@ class TestCursorPaginateSearchJoins:
|
|||||||
search="administrator",
|
search="administrator",
|
||||||
search_fields=[(User.role, Role.name)],
|
search_fields=[(User.role, Role.name)],
|
||||||
items_per_page=20,
|
items_per_page=20,
|
||||||
schema=UserRead,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert isinstance(result.pagination, CursorPagination)
|
assert isinstance(result.pagination, CursorPagination)
|
||||||
@@ -2001,7 +2049,7 @@ class TestCursorPaginateColumnTypes:
|
|||||||
)
|
)
|
||||||
|
|
||||||
page1 = await EventDateTimeCursorCrud.cursor_paginate(
|
page1 = await EventDateTimeCursorCrud.cursor_paginate(
|
||||||
db_session, items_per_page=3, schema=EventRead
|
db_session, items_per_page=3
|
||||||
)
|
)
|
||||||
|
|
||||||
assert isinstance(page1.pagination, CursorPagination)
|
assert isinstance(page1.pagination, CursorPagination)
|
||||||
@@ -2012,7 +2060,6 @@ class TestCursorPaginateColumnTypes:
|
|||||||
db_session,
|
db_session,
|
||||||
cursor=page1.pagination.next_cursor,
|
cursor=page1.pagination.next_cursor,
|
||||||
items_per_page=3,
|
items_per_page=3,
|
||||||
schema=EventRead,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert isinstance(page2.pagination, CursorPagination)
|
assert isinstance(page2.pagination, CursorPagination)
|
||||||
@@ -2040,9 +2087,7 @@ class TestCursorPaginateColumnTypes:
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
page1 = await EventDateCursorCrud.cursor_paginate(
|
page1 = await EventDateCursorCrud.cursor_paginate(db_session, items_per_page=3)
|
||||||
db_session, items_per_page=3, schema=EventRead
|
|
||||||
)
|
|
||||||
|
|
||||||
assert isinstance(page1.pagination, CursorPagination)
|
assert isinstance(page1.pagination, CursorPagination)
|
||||||
assert len(page1.data) == 3
|
assert len(page1.data) == 3
|
||||||
@@ -2052,7 +2097,6 @@ class TestCursorPaginateColumnTypes:
|
|||||||
db_session,
|
db_session,
|
||||||
cursor=page1.pagination.next_cursor,
|
cursor=page1.pagination.next_cursor,
|
||||||
items_per_page=3,
|
items_per_page=3,
|
||||||
schema=EventRead,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert isinstance(page2.pagination, CursorPagination)
|
assert isinstance(page2.pagination, CursorPagination)
|
||||||
@@ -2079,7 +2123,7 @@ class TestCursorPaginateColumnTypes:
|
|||||||
)
|
)
|
||||||
|
|
||||||
page1 = await ProductNumericCursorCrud.cursor_paginate(
|
page1 = await ProductNumericCursorCrud.cursor_paginate(
|
||||||
db_session, items_per_page=3, schema=ProductRead
|
db_session, items_per_page=3
|
||||||
)
|
)
|
||||||
|
|
||||||
assert isinstance(page1.pagination, CursorPagination)
|
assert isinstance(page1.pagination, CursorPagination)
|
||||||
@@ -2090,7 +2134,6 @@ class TestCursorPaginateColumnTypes:
|
|||||||
db_session,
|
db_session,
|
||||||
cursor=page1.pagination.next_cursor,
|
cursor=page1.pagination.next_cursor,
|
||||||
items_per_page=3,
|
items_per_page=3,
|
||||||
schema=ProductRead,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert isinstance(page2.pagination, CursorPagination)
|
assert isinstance(page2.pagination, CursorPagination)
|
||||||
|
|||||||
+37
-69
@@ -23,7 +23,6 @@ from .conftest import (
|
|||||||
User,
|
User,
|
||||||
UserCreate,
|
UserCreate,
|
||||||
UserCrud,
|
UserCrud,
|
||||||
UserRead,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -43,11 +42,10 @@ class TestPaginateSearch:
|
|||||||
db_session, UserCreate(username="bob_smith", email="bob@test.com")
|
db_session, UserCreate(username="bob_smith", email="bob@test.com")
|
||||||
)
|
)
|
||||||
|
|
||||||
result = await UserCrud.offset_paginate(
|
result = await UserCrud.paginate(
|
||||||
db_session,
|
db_session,
|
||||||
search="doe",
|
search="doe",
|
||||||
search_fields=[User.username],
|
search_fields=[User.username],
|
||||||
schema=UserRead,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert isinstance(result.pagination, OffsetPagination)
|
assert isinstance(result.pagination, OffsetPagination)
|
||||||
@@ -63,11 +61,10 @@ class TestPaginateSearch:
|
|||||||
db_session, UserCreate(username="company_bob", email="bob@other.com")
|
db_session, UserCreate(username="company_bob", email="bob@other.com")
|
||||||
)
|
)
|
||||||
|
|
||||||
result = await UserCrud.offset_paginate(
|
result = await UserCrud.paginate(
|
||||||
db_session,
|
db_session,
|
||||||
search="company",
|
search="company",
|
||||||
search_fields=[User.username, User.email],
|
search_fields=[User.username, User.email],
|
||||||
schema=UserRead,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert isinstance(result.pagination, OffsetPagination)
|
assert isinstance(result.pagination, OffsetPagination)
|
||||||
@@ -92,11 +89,10 @@ class TestPaginateSearch:
|
|||||||
UserCreate(username="user1", email="u1@test.com", role_id=user_role.id),
|
UserCreate(username="user1", email="u1@test.com", role_id=user_role.id),
|
||||||
)
|
)
|
||||||
|
|
||||||
result = await UserCrud.offset_paginate(
|
result = await UserCrud.paginate(
|
||||||
db_session,
|
db_session,
|
||||||
search="admin",
|
search="admin",
|
||||||
search_fields=[(User.role, Role.name)],
|
search_fields=[(User.role, Role.name)],
|
||||||
schema=UserRead,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert isinstance(result.pagination, OffsetPagination)
|
assert isinstance(result.pagination, OffsetPagination)
|
||||||
@@ -112,11 +108,10 @@ class TestPaginateSearch:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Search "admin" in username OR role.name
|
# Search "admin" in username OR role.name
|
||||||
result = await UserCrud.offset_paginate(
|
result = await UserCrud.paginate(
|
||||||
db_session,
|
db_session,
|
||||||
search="admin",
|
search="admin",
|
||||||
search_fields=[User.username, (User.role, Role.name)],
|
search_fields=[User.username, (User.role, Role.name)],
|
||||||
schema=UserRead,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert isinstance(result.pagination, OffsetPagination)
|
assert isinstance(result.pagination, OffsetPagination)
|
||||||
@@ -129,11 +124,10 @@ class TestPaginateSearch:
|
|||||||
db_session, UserCreate(username="JohnDoe", email="j@test.com")
|
db_session, UserCreate(username="JohnDoe", email="j@test.com")
|
||||||
)
|
)
|
||||||
|
|
||||||
result = await UserCrud.offset_paginate(
|
result = await UserCrud.paginate(
|
||||||
db_session,
|
db_session,
|
||||||
search="johndoe",
|
search="johndoe",
|
||||||
search_fields=[User.username],
|
search_fields=[User.username],
|
||||||
schema=UserRead,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert isinstance(result.pagination, OffsetPagination)
|
assert isinstance(result.pagination, OffsetPagination)
|
||||||
@@ -147,21 +141,19 @@ class TestPaginateSearch:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Should not find (case mismatch)
|
# Should not find (case mismatch)
|
||||||
result = await UserCrud.offset_paginate(
|
result = await UserCrud.paginate(
|
||||||
db_session,
|
db_session,
|
||||||
search=SearchConfig(query="johndoe", case_sensitive=True),
|
search=SearchConfig(query="johndoe", case_sensitive=True),
|
||||||
search_fields=[User.username],
|
search_fields=[User.username],
|
||||||
schema=UserRead,
|
|
||||||
)
|
)
|
||||||
assert isinstance(result.pagination, OffsetPagination)
|
assert isinstance(result.pagination, OffsetPagination)
|
||||||
assert result.pagination.total_count == 0
|
assert result.pagination.total_count == 0
|
||||||
|
|
||||||
# Should find (case match)
|
# Should find (case match)
|
||||||
result = await UserCrud.offset_paginate(
|
result = await UserCrud.paginate(
|
||||||
db_session,
|
db_session,
|
||||||
search=SearchConfig(query="JohnDoe", case_sensitive=True),
|
search=SearchConfig(query="JohnDoe", case_sensitive=True),
|
||||||
search_fields=[User.username],
|
search_fields=[User.username],
|
||||||
schema=UserRead,
|
|
||||||
)
|
)
|
||||||
assert isinstance(result.pagination, OffsetPagination)
|
assert isinstance(result.pagination, OffsetPagination)
|
||||||
assert result.pagination.total_count == 1
|
assert result.pagination.total_count == 1
|
||||||
@@ -176,13 +168,11 @@ class TestPaginateSearch:
|
|||||||
db_session, UserCreate(username="user2", email="u2@test.com")
|
db_session, UserCreate(username="user2", email="u2@test.com")
|
||||||
)
|
)
|
||||||
|
|
||||||
result = await UserCrud.offset_paginate(db_session, search="", schema=UserRead)
|
result = await UserCrud.paginate(db_session, search="")
|
||||||
assert isinstance(result.pagination, OffsetPagination)
|
assert isinstance(result.pagination, OffsetPagination)
|
||||||
assert result.pagination.total_count == 2
|
assert result.pagination.total_count == 2
|
||||||
|
|
||||||
result = await UserCrud.offset_paginate(
|
result = await UserCrud.paginate(db_session, search=None)
|
||||||
db_session, search=None, schema=UserRead
|
|
||||||
)
|
|
||||||
assert isinstance(result.pagination, OffsetPagination)
|
assert isinstance(result.pagination, OffsetPagination)
|
||||||
assert result.pagination.total_count == 2
|
assert result.pagination.total_count == 2
|
||||||
|
|
||||||
@@ -198,12 +188,11 @@ class TestPaginateSearch:
|
|||||||
UserCreate(username="inactive_john", email="ij@test.com", is_active=False),
|
UserCreate(username="inactive_john", email="ij@test.com", is_active=False),
|
||||||
)
|
)
|
||||||
|
|
||||||
result = await UserCrud.offset_paginate(
|
result = await UserCrud.paginate(
|
||||||
db_session,
|
db_session,
|
||||||
filters=[User.is_active == True], # noqa: E712
|
filters=[User.is_active == True], # noqa: E712
|
||||||
search="john",
|
search="john",
|
||||||
search_fields=[User.username],
|
search_fields=[User.username],
|
||||||
schema=UserRead,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert isinstance(result.pagination, OffsetPagination)
|
assert isinstance(result.pagination, OffsetPagination)
|
||||||
@@ -217,9 +206,7 @@ class TestPaginateSearch:
|
|||||||
db_session, UserCreate(username="findme", email="other@test.com")
|
db_session, UserCreate(username="findme", email="other@test.com")
|
||||||
)
|
)
|
||||||
|
|
||||||
result = await UserCrud.offset_paginate(
|
result = await UserCrud.paginate(db_session, search="findme")
|
||||||
db_session, search="findme", schema=UserRead
|
|
||||||
)
|
|
||||||
|
|
||||||
assert isinstance(result.pagination, OffsetPagination)
|
assert isinstance(result.pagination, OffsetPagination)
|
||||||
assert result.pagination.total_count == 1
|
assert result.pagination.total_count == 1
|
||||||
@@ -231,11 +218,10 @@ class TestPaginateSearch:
|
|||||||
db_session, UserCreate(username="john", email="j@test.com")
|
db_session, UserCreate(username="john", email="j@test.com")
|
||||||
)
|
)
|
||||||
|
|
||||||
result = await UserCrud.offset_paginate(
|
result = await UserCrud.paginate(
|
||||||
db_session,
|
db_session,
|
||||||
search="nonexistent",
|
search="nonexistent",
|
||||||
search_fields=[User.username],
|
search_fields=[User.username],
|
||||||
schema=UserRead,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert isinstance(result.pagination, OffsetPagination)
|
assert isinstance(result.pagination, OffsetPagination)
|
||||||
@@ -251,13 +237,12 @@ class TestPaginateSearch:
|
|||||||
UserCreate(username=f"user_{i}", email=f"user{i}@test.com"),
|
UserCreate(username=f"user_{i}", email=f"user{i}@test.com"),
|
||||||
)
|
)
|
||||||
|
|
||||||
result = await UserCrud.offset_paginate(
|
result = await UserCrud.paginate(
|
||||||
db_session,
|
db_session,
|
||||||
search="user_",
|
search="user_",
|
||||||
search_fields=[User.username],
|
search_fields=[User.username],
|
||||||
page=1,
|
page=1,
|
||||||
items_per_page=5,
|
items_per_page=5,
|
||||||
schema=UserRead,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert isinstance(result.pagination, OffsetPagination)
|
assert isinstance(result.pagination, OffsetPagination)
|
||||||
@@ -279,11 +264,10 @@ class TestPaginateSearch:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Search in username, not in role
|
# Search in username, not in role
|
||||||
result = await UserCrud.offset_paginate(
|
result = await UserCrud.paginate(
|
||||||
db_session,
|
db_session,
|
||||||
search="role",
|
search="role",
|
||||||
search_fields=[User.username],
|
search_fields=[User.username],
|
||||||
schema=UserRead,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert isinstance(result.pagination, OffsetPagination)
|
assert isinstance(result.pagination, OffsetPagination)
|
||||||
@@ -302,12 +286,11 @@ class TestPaginateSearch:
|
|||||||
db_session, UserCreate(username="bob", email="b@test.com")
|
db_session, UserCreate(username="bob", email="b@test.com")
|
||||||
)
|
)
|
||||||
|
|
||||||
result = await UserCrud.offset_paginate(
|
result = await UserCrud.paginate(
|
||||||
db_session,
|
db_session,
|
||||||
search="@test.com",
|
search="@test.com",
|
||||||
search_fields=[User.email],
|
search_fields=[User.email],
|
||||||
order_by=User.username,
|
order_by=User.username,
|
||||||
schema=UserRead,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert isinstance(result.pagination, OffsetPagination)
|
assert isinstance(result.pagination, OffsetPagination)
|
||||||
@@ -327,11 +310,10 @@ class TestPaginateSearch:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Search by UUID (partial match)
|
# Search by UUID (partial match)
|
||||||
result = await UserCrud.offset_paginate(
|
result = await UserCrud.paginate(
|
||||||
db_session,
|
db_session,
|
||||||
search="12345678",
|
search="12345678",
|
||||||
search_fields=[User.id, User.username],
|
search_fields=[User.id, User.username],
|
||||||
schema=UserRead,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert isinstance(result.pagination, OffsetPagination)
|
assert isinstance(result.pagination, OffsetPagination)
|
||||||
@@ -381,11 +363,10 @@ class TestSearchConfig:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# 'john' must be in username AND email
|
# 'john' must be in username AND email
|
||||||
result = await UserCrud.offset_paginate(
|
result = await UserCrud.paginate(
|
||||||
db_session,
|
db_session,
|
||||||
search=SearchConfig(query="john", match_mode="all"),
|
search=SearchConfig(query="john", match_mode="all"),
|
||||||
search_fields=[User.username, User.email],
|
search_fields=[User.username, User.email],
|
||||||
schema=UserRead,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert isinstance(result.pagination, OffsetPagination)
|
assert isinstance(result.pagination, OffsetPagination)
|
||||||
@@ -399,10 +380,9 @@ class TestSearchConfig:
|
|||||||
db_session, UserCreate(username="test", email="findme@test.com")
|
db_session, UserCreate(username="test", email="findme@test.com")
|
||||||
)
|
)
|
||||||
|
|
||||||
result = await UserCrud.offset_paginate(
|
result = await UserCrud.paginate(
|
||||||
db_session,
|
db_session,
|
||||||
search=SearchConfig(query="findme", fields=[User.email]),
|
search=SearchConfig(query="findme", fields=[User.email]),
|
||||||
schema=UserRead,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert isinstance(result.pagination, OffsetPagination)
|
assert isinstance(result.pagination, OffsetPagination)
|
||||||
@@ -498,7 +478,7 @@ class TestFacetsNotSet:
|
|||||||
db_session, UserCreate(username="alice", email="a@test.com")
|
db_session, UserCreate(username="alice", email="a@test.com")
|
||||||
)
|
)
|
||||||
|
|
||||||
result = await UserCrud.offset_paginate(db_session, schema=UserRead)
|
result = await UserCrud.offset_paginate(db_session)
|
||||||
|
|
||||||
assert result.filter_attributes is None
|
assert result.filter_attributes is None
|
||||||
|
|
||||||
@@ -510,7 +490,7 @@ class TestFacetsNotSet:
|
|||||||
db_session, UserCreate(username="alice", email="a@test.com")
|
db_session, UserCreate(username="alice", email="a@test.com")
|
||||||
)
|
)
|
||||||
|
|
||||||
result = await UserCursorCrud.cursor_paginate(db_session, schema=UserRead)
|
result = await UserCursorCrud.cursor_paginate(db_session)
|
||||||
|
|
||||||
assert result.filter_attributes is None
|
assert result.filter_attributes is None
|
||||||
|
|
||||||
@@ -529,7 +509,7 @@ class TestFacetsDirectColumn:
|
|||||||
db_session, UserCreate(username="bob", email="b@test.com")
|
db_session, UserCreate(username="bob", email="b@test.com")
|
||||||
)
|
)
|
||||||
|
|
||||||
result = await UserFacetCrud.offset_paginate(db_session, schema=UserRead)
|
result = await UserFacetCrud.offset_paginate(db_session)
|
||||||
|
|
||||||
assert result.filter_attributes is not None
|
assert result.filter_attributes is not None
|
||||||
# Distinct usernames, sorted
|
# Distinct usernames, sorted
|
||||||
@@ -548,7 +528,7 @@ class TestFacetsDirectColumn:
|
|||||||
db_session, UserCreate(username="bob", email="b@test.com")
|
db_session, UserCreate(username="bob", email="b@test.com")
|
||||||
)
|
)
|
||||||
|
|
||||||
result = await UserFacetCursorCrud.cursor_paginate(db_session, schema=UserRead)
|
result = await UserFacetCursorCrud.cursor_paginate(db_session)
|
||||||
|
|
||||||
assert result.filter_attributes is not None
|
assert result.filter_attributes is not None
|
||||||
assert set(result.filter_attributes["email"]) == {"a@test.com", "b@test.com"}
|
assert set(result.filter_attributes["email"]) == {"a@test.com", "b@test.com"}
|
||||||
@@ -564,7 +544,7 @@ class TestFacetsDirectColumn:
|
|||||||
db_session, UserCreate(username="bob", email="b@test.com")
|
db_session, UserCreate(username="bob", email="b@test.com")
|
||||||
)
|
)
|
||||||
|
|
||||||
result = await UserFacetCrud.offset_paginate(db_session, schema=UserRead)
|
result = await UserFacetCrud.offset_paginate(db_session)
|
||||||
|
|
||||||
assert result.filter_attributes is not None
|
assert result.filter_attributes is not None
|
||||||
assert "username" in result.filter_attributes
|
assert "username" in result.filter_attributes
|
||||||
@@ -581,7 +561,7 @@ class TestFacetsDirectColumn:
|
|||||||
|
|
||||||
# Override: ask for email instead of username
|
# Override: ask for email instead of username
|
||||||
result = await UserFacetCrud.offset_paginate(
|
result = await UserFacetCrud.offset_paginate(
|
||||||
db_session, facet_fields=[User.email], schema=UserRead
|
db_session, facet_fields=[User.email]
|
||||||
)
|
)
|
||||||
|
|
||||||
assert result.filter_attributes is not None
|
assert result.filter_attributes is not None
|
||||||
@@ -607,7 +587,6 @@ class TestFacetsRespectFilters:
|
|||||||
result = await UserFacetCrud.offset_paginate(
|
result = await UserFacetCrud.offset_paginate(
|
||||||
db_session,
|
db_session,
|
||||||
filters=[User.is_active == True], # noqa: E712
|
filters=[User.is_active == True], # noqa: E712
|
||||||
schema=UserRead,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert result.filter_attributes is not None
|
assert result.filter_attributes is not None
|
||||||
@@ -638,7 +617,7 @@ class TestFacetsRelationship:
|
|||||||
db_session, UserCreate(username="charlie", email="c@test.com")
|
db_session, UserCreate(username="charlie", email="c@test.com")
|
||||||
)
|
)
|
||||||
|
|
||||||
result = await UserRelFacetCrud.offset_paginate(db_session, schema=UserRead)
|
result = await UserRelFacetCrud.offset_paginate(db_session)
|
||||||
|
|
||||||
assert result.filter_attributes is not None
|
assert result.filter_attributes is not None
|
||||||
assert set(result.filter_attributes["name"]) == {"admin", "editor"}
|
assert set(result.filter_attributes["name"]) == {"admin", "editor"}
|
||||||
@@ -653,7 +632,7 @@ class TestFacetsRelationship:
|
|||||||
db_session, UserCreate(username="norole", email="n@test.com")
|
db_session, UserCreate(username="norole", email="n@test.com")
|
||||||
)
|
)
|
||||||
|
|
||||||
result = await UserRelFacetCrud.offset_paginate(db_session, schema=UserRead)
|
result = await UserRelFacetCrud.offset_paginate(db_session)
|
||||||
|
|
||||||
assert result.filter_attributes is not None
|
assert result.filter_attributes is not None
|
||||||
assert result.filter_attributes["name"] == []
|
assert result.filter_attributes["name"] == []
|
||||||
@@ -677,10 +656,7 @@ class TestFacetsRelationship:
|
|||||||
)
|
)
|
||||||
|
|
||||||
result = await UserSearchFacetCrud.offset_paginate(
|
result = await UserSearchFacetCrud.offset_paginate(
|
||||||
db_session,
|
db_session, search="admin", search_fields=[(User.role, Role.name)]
|
||||||
search="admin",
|
|
||||||
search_fields=[(User.role, Role.name)],
|
|
||||||
schema=UserRead,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert result.filter_attributes is not None
|
assert result.filter_attributes is not None
|
||||||
@@ -702,7 +678,7 @@ class TestFilterBy:
|
|||||||
)
|
)
|
||||||
|
|
||||||
result = await UserFacetCrud.offset_paginate(
|
result = await UserFacetCrud.offset_paginate(
|
||||||
db_session, filter_by={"username": "alice"}, schema=UserRead
|
db_session, filter_by={"username": "alice"}
|
||||||
)
|
)
|
||||||
|
|
||||||
assert len(result.data) == 1
|
assert len(result.data) == 1
|
||||||
@@ -725,7 +701,7 @@ class TestFilterBy:
|
|||||||
)
|
)
|
||||||
|
|
||||||
result = await UserFacetCrud.offset_paginate(
|
result = await UserFacetCrud.offset_paginate(
|
||||||
db_session, filter_by={"username": ["alice", "bob"]}, schema=UserRead
|
db_session, filter_by={"username": ["alice", "bob"]}
|
||||||
)
|
)
|
||||||
|
|
||||||
assert isinstance(result.pagination, OffsetPagination)
|
assert isinstance(result.pagination, OffsetPagination)
|
||||||
@@ -750,7 +726,7 @@ class TestFilterBy:
|
|||||||
)
|
)
|
||||||
|
|
||||||
result = await UserRelFacetCrud.offset_paginate(
|
result = await UserRelFacetCrud.offset_paginate(
|
||||||
db_session, filter_by={"name": "admin"}, schema=UserRead
|
db_session, filter_by={"name": "admin"}
|
||||||
)
|
)
|
||||||
|
|
||||||
assert isinstance(result.pagination, OffsetPagination)
|
assert isinstance(result.pagination, OffsetPagination)
|
||||||
@@ -773,7 +749,6 @@ class TestFilterBy:
|
|||||||
db_session,
|
db_session,
|
||||||
filters=[User.is_active == True], # noqa: E712
|
filters=[User.is_active == True], # noqa: E712
|
||||||
filter_by={"username": ["alice", "alice2"]},
|
filter_by={"username": ["alice", "alice2"]},
|
||||||
schema=UserRead,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Only alice passes both: is_active=True AND username IN [alice, alice2]
|
# Only alice passes both: is_active=True AND username IN [alice, alice2]
|
||||||
@@ -788,7 +763,7 @@ class TestFilterBy:
|
|||||||
|
|
||||||
with pytest.raises(InvalidFacetFilterError) as exc_info:
|
with pytest.raises(InvalidFacetFilterError) as exc_info:
|
||||||
await UserFacetCrud.offset_paginate(
|
await UserFacetCrud.offset_paginate(
|
||||||
db_session, filter_by={"nonexistent": "value"}, schema=UserRead
|
db_session, filter_by={"nonexistent": "value"}
|
||||||
)
|
)
|
||||||
|
|
||||||
assert exc_info.value.key == "nonexistent"
|
assert exc_info.value.key == "nonexistent"
|
||||||
@@ -820,7 +795,6 @@ class TestFilterBy:
|
|||||||
result = await UserRoleFacetCrud.offset_paginate(
|
result = await UserRoleFacetCrud.offset_paginate(
|
||||||
db_session,
|
db_session,
|
||||||
filter_by={"name": "admin", "id": str(admin.id)},
|
filter_by={"name": "admin", "id": str(admin.id)},
|
||||||
schema=UserRead,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert isinstance(result.pagination, OffsetPagination)
|
assert isinstance(result.pagination, OffsetPagination)
|
||||||
@@ -841,7 +815,7 @@ class TestFilterBy:
|
|||||||
)
|
)
|
||||||
|
|
||||||
result = await UserFacetCursorCrud.cursor_paginate(
|
result = await UserFacetCursorCrud.cursor_paginate(
|
||||||
db_session, filter_by={"username": "alice"}, schema=UserRead
|
db_session, filter_by={"username": "alice"}
|
||||||
)
|
)
|
||||||
|
|
||||||
assert len(result.data) == 1
|
assert len(result.data) == 1
|
||||||
@@ -865,7 +839,7 @@ class TestFilterBy:
|
|||||||
)
|
)
|
||||||
|
|
||||||
result = await UserFacetCrud.offset_paginate(
|
result = await UserFacetCrud.offset_paginate(
|
||||||
db_session, filter_by=UserFilter(username="alice"), schema=UserRead
|
db_session, filter_by=UserFilter(username="alice")
|
||||||
)
|
)
|
||||||
|
|
||||||
assert isinstance(result.pagination, OffsetPagination)
|
assert isinstance(result.pagination, OffsetPagination)
|
||||||
@@ -891,7 +865,7 @@ class TestFilterBy:
|
|||||||
)
|
)
|
||||||
|
|
||||||
result = await UserFacetCursorCrud.cursor_paginate(
|
result = await UserFacetCursorCrud.cursor_paginate(
|
||||||
db_session, filter_by=UserFilter(username="alice"), schema=UserRead
|
db_session, filter_by=UserFilter(username="alice")
|
||||||
)
|
)
|
||||||
|
|
||||||
assert len(result.data) == 1
|
assert len(result.data) == 1
|
||||||
@@ -1000,9 +974,7 @@ class TestFilterParamsSchema:
|
|||||||
|
|
||||||
dep = UserFacetCrud.filter_params()
|
dep = UserFacetCrud.filter_params()
|
||||||
f = await dep(username=["alice"])
|
f = await dep(username=["alice"])
|
||||||
result = await UserFacetCrud.offset_paginate(
|
result = await UserFacetCrud.offset_paginate(db_session, filter_by=f)
|
||||||
db_session, filter_by=f, schema=UserRead
|
|
||||||
)
|
|
||||||
|
|
||||||
assert isinstance(result.pagination, OffsetPagination)
|
assert isinstance(result.pagination, OffsetPagination)
|
||||||
assert result.pagination.total_count == 1
|
assert result.pagination.total_count == 1
|
||||||
@@ -1023,9 +995,7 @@ class TestFilterParamsSchema:
|
|||||||
|
|
||||||
dep = UserFacetCursorCrud.filter_params()
|
dep = UserFacetCursorCrud.filter_params()
|
||||||
f = await dep(username=["alice"])
|
f = await dep(username=["alice"])
|
||||||
result = await UserFacetCursorCrud.cursor_paginate(
|
result = await UserFacetCursorCrud.cursor_paginate(db_session, filter_by=f)
|
||||||
db_session, filter_by=f, schema=UserRead
|
|
||||||
)
|
|
||||||
|
|
||||||
assert len(result.data) == 1
|
assert len(result.data) == 1
|
||||||
assert result.data[0].username == "alice"
|
assert result.data[0].username == "alice"
|
||||||
@@ -1043,9 +1013,7 @@ class TestFilterParamsSchema:
|
|||||||
|
|
||||||
dep = UserFacetCrud.filter_params()
|
dep = UserFacetCrud.filter_params()
|
||||||
f = await dep() # all fields None
|
f = await dep() # all fields None
|
||||||
result = await UserFacetCrud.offset_paginate(
|
result = await UserFacetCrud.offset_paginate(db_session, filter_by=f)
|
||||||
db_session, filter_by=f, schema=UserRead
|
|
||||||
)
|
|
||||||
|
|
||||||
assert isinstance(result.pagination, OffsetPagination)
|
assert isinstance(result.pagination, OffsetPagination)
|
||||||
assert result.pagination.total_count == 2
|
assert result.pagination.total_count == 2
|
||||||
|
|||||||
+1
-59
@@ -14,9 +14,7 @@ from fastapi_toolsets.fixtures import (
|
|||||||
load_fixtures_by_context,
|
load_fixtures_by_context,
|
||||||
)
|
)
|
||||||
|
|
||||||
from fastapi_toolsets.fixtures.utils import _get_primary_key
|
from .conftest import Role, User
|
||||||
|
|
||||||
from .conftest import IntRole, Permission, Role, User
|
|
||||||
|
|
||||||
|
|
||||||
class TestContext:
|
class TestContext:
|
||||||
@@ -599,46 +597,6 @@ class TestLoadFixtures:
|
|||||||
count = await RoleCrud.count(db_session)
|
count = await RoleCrud.count(db_session)
|
||||||
assert count == 2
|
assert count == 2
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_skip_existing_skips_if_record_exists(self, db_session: AsyncSession):
|
|
||||||
"""SKIP_EXISTING returns empty loaded list when the record already exists."""
|
|
||||||
registry = FixtureRegistry()
|
|
||||||
role_id = uuid.uuid4()
|
|
||||||
|
|
||||||
@registry.register
|
|
||||||
def roles():
|
|
||||||
return [Role(id=role_id, name="admin")]
|
|
||||||
|
|
||||||
# First load — inserts the record.
|
|
||||||
result1 = await load_fixtures(
|
|
||||||
db_session, registry, "roles", strategy=LoadStrategy.SKIP_EXISTING
|
|
||||||
)
|
|
||||||
assert len(result1["roles"]) == 1
|
|
||||||
|
|
||||||
# Remove from identity map so session.get() queries the DB in the second load.
|
|
||||||
db_session.expunge_all()
|
|
||||||
|
|
||||||
# Second load — record exists in DB, nothing should be added.
|
|
||||||
result2 = await load_fixtures(
|
|
||||||
db_session, registry, "roles", strategy=LoadStrategy.SKIP_EXISTING
|
|
||||||
)
|
|
||||||
assert result2["roles"] == []
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_skip_existing_null_pk_inserts(self, db_session: AsyncSession):
|
|
||||||
"""SKIP_EXISTING inserts when the instance has no PK set (auto-increment)."""
|
|
||||||
registry = FixtureRegistry()
|
|
||||||
|
|
||||||
@registry.register
|
|
||||||
def int_roles():
|
|
||||||
# No id provided — PK is None before INSERT (autoincrement).
|
|
||||||
return [IntRole(name="member")]
|
|
||||||
|
|
||||||
result = await load_fixtures(
|
|
||||||
db_session, registry, "int_roles", strategy=LoadStrategy.SKIP_EXISTING
|
|
||||||
)
|
|
||||||
assert len(result["int_roles"]) == 1
|
|
||||||
|
|
||||||
|
|
||||||
class TestLoadFixturesByContext:
|
class TestLoadFixturesByContext:
|
||||||
"""Tests for load_fixtures_by_context function."""
|
"""Tests for load_fixtures_by_context function."""
|
||||||
@@ -797,19 +755,3 @@ class TestGetObjByAttr:
|
|||||||
"""Raises StopIteration when value type doesn't match."""
|
"""Raises StopIteration when value type doesn't match."""
|
||||||
with pytest.raises(StopIteration):
|
with pytest.raises(StopIteration):
|
||||||
get_obj_by_attr(self.roles, "id", "not-a-uuid")
|
get_obj_by_attr(self.roles, "id", "not-a-uuid")
|
||||||
|
|
||||||
|
|
||||||
class TestGetPrimaryKey:
|
|
||||||
"""Unit tests for the _get_primary_key helper (composite PK paths)."""
|
|
||||||
|
|
||||||
def test_composite_pk_all_set(self):
|
|
||||||
"""Returns a tuple when all composite PK values are set."""
|
|
||||||
instance = Permission(subject="post", action="read")
|
|
||||||
pk = _get_primary_key(instance)
|
|
||||||
assert pk == ("post", "read")
|
|
||||||
|
|
||||||
def test_composite_pk_partial_none(self):
|
|
||||||
"""Returns None when any composite PK value is None."""
|
|
||||||
instance = Permission(subject="post") # action is None
|
|
||||||
pk = _get_primary_key(instance)
|
|
||||||
assert pk is None
|
|
||||||
|
|||||||
+30
-5
@@ -9,6 +9,7 @@ from fastapi_toolsets.schemas import (
|
|||||||
ErrorResponse,
|
ErrorResponse,
|
||||||
OffsetPagination,
|
OffsetPagination,
|
||||||
PaginatedResponse,
|
PaginatedResponse,
|
||||||
|
Pagination,
|
||||||
Response,
|
Response,
|
||||||
ResponseStatus,
|
ResponseStatus,
|
||||||
)
|
)
|
||||||
@@ -198,6 +199,20 @@ class TestOffsetPagination:
|
|||||||
assert data["page"] == 2
|
assert data["page"] == 2
|
||||||
assert data["has_more"] is True
|
assert data["has_more"] is True
|
||||||
|
|
||||||
|
def test_pagination_alias_is_offset_pagination(self):
|
||||||
|
"""Pagination is a backward-compatible alias for OffsetPagination."""
|
||||||
|
assert Pagination is OffsetPagination
|
||||||
|
|
||||||
|
def test_pagination_alias_constructs_offset_pagination(self):
|
||||||
|
"""Code using Pagination(...) still works unchanged."""
|
||||||
|
pagination = Pagination(
|
||||||
|
total_count=10,
|
||||||
|
items_per_page=5,
|
||||||
|
page=2,
|
||||||
|
has_more=False,
|
||||||
|
)
|
||||||
|
assert isinstance(pagination, OffsetPagination)
|
||||||
|
|
||||||
|
|
||||||
class TestCursorPagination:
|
class TestCursorPagination:
|
||||||
"""Tests for CursorPagination schema."""
|
"""Tests for CursorPagination schema."""
|
||||||
@@ -261,7 +276,7 @@ class TestPaginatedResponse:
|
|||||||
|
|
||||||
def test_create_paginated_response(self):
|
def test_create_paginated_response(self):
|
||||||
"""Create PaginatedResponse with data and pagination."""
|
"""Create PaginatedResponse with data and pagination."""
|
||||||
pagination = OffsetPagination(
|
pagination = Pagination(
|
||||||
total_count=30,
|
total_count=30,
|
||||||
items_per_page=10,
|
items_per_page=10,
|
||||||
page=1,
|
page=1,
|
||||||
@@ -279,7 +294,7 @@ class TestPaginatedResponse:
|
|||||||
|
|
||||||
def test_with_custom_message(self):
|
def test_with_custom_message(self):
|
||||||
"""PaginatedResponse with custom message."""
|
"""PaginatedResponse with custom message."""
|
||||||
pagination = OffsetPagination(
|
pagination = Pagination(
|
||||||
total_count=5,
|
total_count=5,
|
||||||
items_per_page=10,
|
items_per_page=10,
|
||||||
page=1,
|
page=1,
|
||||||
@@ -295,7 +310,7 @@ class TestPaginatedResponse:
|
|||||||
|
|
||||||
def test_empty_data(self):
|
def test_empty_data(self):
|
||||||
"""PaginatedResponse with empty data."""
|
"""PaginatedResponse with empty data."""
|
||||||
pagination = OffsetPagination(
|
pagination = Pagination(
|
||||||
total_count=0,
|
total_count=0,
|
||||||
items_per_page=10,
|
items_per_page=10,
|
||||||
page=1,
|
page=1,
|
||||||
@@ -317,7 +332,7 @@ class TestPaginatedResponse:
|
|||||||
id: int
|
id: int
|
||||||
name: str
|
name: str
|
||||||
|
|
||||||
pagination = OffsetPagination(
|
pagination = Pagination(
|
||||||
total_count=1,
|
total_count=1,
|
||||||
items_per_page=10,
|
items_per_page=10,
|
||||||
page=1,
|
page=1,
|
||||||
@@ -332,7 +347,7 @@ class TestPaginatedResponse:
|
|||||||
|
|
||||||
def test_serialization(self):
|
def test_serialization(self):
|
||||||
"""PaginatedResponse serializes correctly."""
|
"""PaginatedResponse serializes correctly."""
|
||||||
pagination = OffsetPagination(
|
pagination = Pagination(
|
||||||
total_count=100,
|
total_count=100,
|
||||||
items_per_page=10,
|
items_per_page=10,
|
||||||
page=5,
|
page=5,
|
||||||
@@ -370,6 +385,16 @@ class TestPaginatedResponse:
|
|||||||
)
|
)
|
||||||
assert isinstance(response.pagination, CursorPagination)
|
assert isinstance(response.pagination, CursorPagination)
|
||||||
|
|
||||||
|
def test_pagination_alias_accepted(self):
|
||||||
|
"""Constructing PaginatedResponse with Pagination (alias) still works."""
|
||||||
|
response = PaginatedResponse(
|
||||||
|
data=[],
|
||||||
|
pagination=Pagination(
|
||||||
|
total_count=0, items_per_page=10, page=1, has_more=False
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assert isinstance(response.pagination, OffsetPagination)
|
||||||
|
|
||||||
|
|
||||||
class TestFromAttributes:
|
class TestFromAttributes:
|
||||||
"""Tests for from_attributes config (ORM mode)."""
|
"""Tests for from_attributes config (ORM mode)."""
|
||||||
|
|||||||
@@ -0,0 +1,902 @@
|
|||||||
|
"""Tests for fastapi_toolsets.security."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import FastAPI, Security
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from fastapi_toolsets.exceptions import UnauthorizedError, init_exceptions_handlers
|
||||||
|
from fastapi_toolsets.security import (
|
||||||
|
AuthSource,
|
||||||
|
BearerTokenAuth,
|
||||||
|
CookieAuth,
|
||||||
|
MultiAuth,
|
||||||
|
OAuth2Auth,
|
||||||
|
OpenIDAuth,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _app(*routes_setup_fns):
|
||||||
|
"""Build a minimal FastAPI test app with exception handlers."""
|
||||||
|
app = FastAPI()
|
||||||
|
init_exceptions_handlers(app)
|
||||||
|
for fn in routes_setup_fns:
|
||||||
|
fn(app)
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
VALID_TOKEN = "secret"
|
||||||
|
VALID_COOKIE = "session123"
|
||||||
|
|
||||||
|
|
||||||
|
async def simple_validator(credential: str) -> dict:
|
||||||
|
if credential != VALID_TOKEN:
|
||||||
|
raise UnauthorizedError()
|
||||||
|
return {"user": "alice"}
|
||||||
|
|
||||||
|
|
||||||
|
async def role_validator(credential: str, *, role: str) -> dict:
|
||||||
|
if credential != VALID_TOKEN:
|
||||||
|
raise UnauthorizedError()
|
||||||
|
return {"user": "alice", "role": role}
|
||||||
|
|
||||||
|
|
||||||
|
async def cookie_validator(value: str) -> dict:
|
||||||
|
if value != VALID_COOKIE:
|
||||||
|
raise UnauthorizedError()
|
||||||
|
return {"session": value}
|
||||||
|
|
||||||
|
|
||||||
|
class TestBearerTokenAuth:
|
||||||
|
def test_valid_token_returns_identity(self):
|
||||||
|
bearer = BearerTokenAuth(simple_validator)
|
||||||
|
|
||||||
|
def setup(app: FastAPI):
|
||||||
|
@app.get("/me")
|
||||||
|
async def me(user=Security(bearer)):
|
||||||
|
return user
|
||||||
|
|
||||||
|
client = TestClient(_app(setup))
|
||||||
|
response = client.get("/me", headers={"Authorization": f"Bearer {VALID_TOKEN}"})
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"user": "alice"}
|
||||||
|
|
||||||
|
def test_missing_header_returns_401(self):
|
||||||
|
bearer = BearerTokenAuth(simple_validator)
|
||||||
|
|
||||||
|
def setup(app: FastAPI):
|
||||||
|
@app.get("/me")
|
||||||
|
async def me(user=Security(bearer)):
|
||||||
|
return user
|
||||||
|
|
||||||
|
client = TestClient(_app(setup))
|
||||||
|
response = client.get("/me")
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
def test_invalid_token_returns_401(self):
|
||||||
|
bearer = BearerTokenAuth(simple_validator)
|
||||||
|
|
||||||
|
def setup(app: FastAPI):
|
||||||
|
@app.get("/me")
|
||||||
|
async def me(user=Security(bearer)):
|
||||||
|
return user
|
||||||
|
|
||||||
|
client = TestClient(_app(setup))
|
||||||
|
response = client.get("/me", headers={"Authorization": "Bearer wrong"})
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
def test_kwargs_forwarded_to_validator(self):
|
||||||
|
bearer = BearerTokenAuth(role_validator, role="admin")
|
||||||
|
|
||||||
|
def setup(app: FastAPI):
|
||||||
|
@app.get("/me")
|
||||||
|
async def me(user=Security(bearer)):
|
||||||
|
return user
|
||||||
|
|
||||||
|
client = TestClient(_app(setup))
|
||||||
|
response = client.get("/me", headers={"Authorization": f"Bearer {VALID_TOKEN}"})
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"user": "alice", "role": "admin"}
|
||||||
|
|
||||||
|
def test_prefix_matching_passes_full_token(self):
|
||||||
|
"""Token with matching prefix: full token (with prefix) is passed to validator."""
|
||||||
|
received: list[str] = []
|
||||||
|
|
||||||
|
async def capturing_validator(credential: str) -> dict:
|
||||||
|
received.append(credential)
|
||||||
|
return {"user": "alice"}
|
||||||
|
|
||||||
|
bearer = BearerTokenAuth(capturing_validator, prefix="user_")
|
||||||
|
|
||||||
|
def setup(app: FastAPI):
|
||||||
|
@app.get("/me")
|
||||||
|
async def me(user=Security(bearer)):
|
||||||
|
return user
|
||||||
|
|
||||||
|
client = TestClient(_app(setup))
|
||||||
|
response = client.get("/me", headers={"Authorization": "Bearer user_abc123"})
|
||||||
|
assert response.status_code == 200
|
||||||
|
# Prefix is kept — validator receives the full token as stored in DB
|
||||||
|
assert received == ["user_abc123"]
|
||||||
|
|
||||||
|
def test_prefix_mismatch_returns_401(self):
|
||||||
|
bearer = BearerTokenAuth(simple_validator, prefix="user_")
|
||||||
|
|
||||||
|
def setup(app: FastAPI):
|
||||||
|
@app.get("/me")
|
||||||
|
async def me(user=Security(bearer)):
|
||||||
|
return user
|
||||||
|
|
||||||
|
client = TestClient(_app(setup))
|
||||||
|
response = client.get("/me", headers={"Authorization": "Bearer org_abc123"})
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
# --- extract() ---
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_extract_no_header(self):
|
||||||
|
from starlette.requests import Request
|
||||||
|
|
||||||
|
bearer = BearerTokenAuth(simple_validator)
|
||||||
|
scope = {"type": "http", "method": "GET", "path": "/", "headers": []}
|
||||||
|
request = Request(scope)
|
||||||
|
assert await bearer.extract(request) is None
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_extract_empty_token(self):
|
||||||
|
from starlette.requests import Request
|
||||||
|
|
||||||
|
bearer = BearerTokenAuth(simple_validator)
|
||||||
|
scope = {
|
||||||
|
"type": "http",
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/",
|
||||||
|
"headers": [(b"authorization", b"Bearer ")],
|
||||||
|
}
|
||||||
|
request = Request(scope)
|
||||||
|
assert await bearer.extract(request) is None
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_extract_no_prefix(self):
|
||||||
|
from starlette.requests import Request
|
||||||
|
|
||||||
|
bearer = BearerTokenAuth(simple_validator)
|
||||||
|
scope = {
|
||||||
|
"type": "http",
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/",
|
||||||
|
"headers": [(b"authorization", b"Bearer mytoken")],
|
||||||
|
}
|
||||||
|
request = Request(scope)
|
||||||
|
assert await bearer.extract(request) == "mytoken"
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_extract_prefix_match(self):
|
||||||
|
from starlette.requests import Request
|
||||||
|
|
||||||
|
bearer = BearerTokenAuth(simple_validator, prefix="user_")
|
||||||
|
scope = {
|
||||||
|
"type": "http",
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/",
|
||||||
|
"headers": [(b"authorization", b"Bearer user_abc")],
|
||||||
|
}
|
||||||
|
request = Request(scope)
|
||||||
|
assert await bearer.extract(request) == "user_abc"
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_extract_prefix_no_match(self):
|
||||||
|
from starlette.requests import Request
|
||||||
|
|
||||||
|
bearer = BearerTokenAuth(simple_validator, prefix="user_")
|
||||||
|
scope = {
|
||||||
|
"type": "http",
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/",
|
||||||
|
"headers": [(b"authorization", b"Bearer org_abc")],
|
||||||
|
}
|
||||||
|
request = Request(scope)
|
||||||
|
assert await bearer.extract(request) is None
|
||||||
|
|
||||||
|
# --- generate_token() ---
|
||||||
|
|
||||||
|
def test_generate_token_no_prefix(self):
|
||||||
|
bearer = BearerTokenAuth(simple_validator)
|
||||||
|
token = bearer.generate_token()
|
||||||
|
assert isinstance(token, str)
|
||||||
|
assert len(token) > 0
|
||||||
|
|
||||||
|
def test_generate_token_with_prefix(self):
|
||||||
|
bearer = BearerTokenAuth(simple_validator, prefix="user_")
|
||||||
|
token = bearer.generate_token()
|
||||||
|
assert token.startswith("user_")
|
||||||
|
|
||||||
|
def test_generate_token_uniqueness(self):
|
||||||
|
bearer = BearerTokenAuth(simple_validator)
|
||||||
|
assert bearer.generate_token() != bearer.generate_token()
|
||||||
|
|
||||||
|
def test_generate_token_is_valid_credential(self):
|
||||||
|
"""A generated token (with prefix) is accepted by the same auth source."""
|
||||||
|
stored: list[str] = []
|
||||||
|
|
||||||
|
async def storing_validator(credential: str) -> dict:
|
||||||
|
stored.append(credential)
|
||||||
|
return {"token": credential}
|
||||||
|
|
||||||
|
bearer = BearerTokenAuth(storing_validator, prefix="user_")
|
||||||
|
token = bearer.generate_token()
|
||||||
|
|
||||||
|
def setup(app: FastAPI):
|
||||||
|
@app.get("/me")
|
||||||
|
async def me(user=Security(bearer)):
|
||||||
|
return user
|
||||||
|
|
||||||
|
client = TestClient(_app(setup))
|
||||||
|
response = client.get("/me", headers={"Authorization": f"Bearer {token}"})
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert stored == [token]
|
||||||
|
|
||||||
|
|
||||||
|
class TestCookieAuth:
|
||||||
|
def test_valid_cookie_returns_identity(self):
|
||||||
|
cookie_auth = CookieAuth("session", cookie_validator)
|
||||||
|
|
||||||
|
def setup(app: FastAPI):
|
||||||
|
@app.get("/me")
|
||||||
|
async def me(user=Security(cookie_auth)):
|
||||||
|
return user
|
||||||
|
|
||||||
|
client = TestClient(_app(setup))
|
||||||
|
response = client.get("/me", cookies={"session": VALID_COOKIE})
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"session": VALID_COOKIE}
|
||||||
|
|
||||||
|
def test_missing_cookie_returns_401(self):
|
||||||
|
cookie_auth = CookieAuth("session", cookie_validator)
|
||||||
|
|
||||||
|
def setup(app: FastAPI):
|
||||||
|
@app.get("/me")
|
||||||
|
async def me(user=Security(cookie_auth)):
|
||||||
|
return user
|
||||||
|
|
||||||
|
client = TestClient(_app(setup))
|
||||||
|
response = client.get("/me")
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
def test_invalid_cookie_returns_401(self):
|
||||||
|
cookie_auth = CookieAuth("session", cookie_validator)
|
||||||
|
|
||||||
|
def setup(app: FastAPI):
|
||||||
|
@app.get("/me")
|
||||||
|
async def me(user=Security(cookie_auth)):
|
||||||
|
return user
|
||||||
|
|
||||||
|
client = TestClient(_app(setup))
|
||||||
|
response = client.get("/me", cookies={"session": "wrong"})
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
def test_kwargs_forwarded_to_validator(self):
|
||||||
|
async def session_validator(value: str, *, scope: str) -> dict:
|
||||||
|
if value != VALID_COOKIE:
|
||||||
|
raise UnauthorizedError()
|
||||||
|
return {"session": value, "scope": scope}
|
||||||
|
|
||||||
|
cookie_auth = CookieAuth("session", session_validator, scope="read")
|
||||||
|
|
||||||
|
def setup(app: FastAPI):
|
||||||
|
@app.get("/me")
|
||||||
|
async def me(user=Security(cookie_auth)):
|
||||||
|
return user
|
||||||
|
|
||||||
|
client = TestClient(_app(setup))
|
||||||
|
response = client.get("/me", cookies={"session": VALID_COOKIE})
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"session": VALID_COOKIE, "scope": "read"}
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_extract_no_cookie(self):
|
||||||
|
from starlette.requests import Request
|
||||||
|
|
||||||
|
auth = CookieAuth("session", cookie_validator)
|
||||||
|
scope = {"type": "http", "method": "GET", "path": "/", "headers": []}
|
||||||
|
request = Request(scope)
|
||||||
|
assert await auth.extract(request) is None
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_extract_cookie_present(self):
|
||||||
|
from starlette.requests import Request
|
||||||
|
|
||||||
|
auth = CookieAuth("session", cookie_validator)
|
||||||
|
scope = {
|
||||||
|
"type": "http",
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/",
|
||||||
|
"headers": [(b"cookie", b"session=abc")],
|
||||||
|
}
|
||||||
|
request = Request(scope)
|
||||||
|
assert await auth.extract(request) == "abc"
|
||||||
|
|
||||||
|
|
||||||
|
class TestOAuth2Auth:
|
||||||
|
def test_valid_token_returns_identity(self):
|
||||||
|
oauth = OAuth2Auth(token_url="/token", validator=simple_validator)
|
||||||
|
|
||||||
|
def setup(app: FastAPI):
|
||||||
|
@app.get("/me")
|
||||||
|
async def me(user=Security(oauth)):
|
||||||
|
return user
|
||||||
|
|
||||||
|
client = TestClient(_app(setup))
|
||||||
|
response = client.get("/me", headers={"Authorization": f"Bearer {VALID_TOKEN}"})
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"user": "alice"}
|
||||||
|
|
||||||
|
def test_missing_token_returns_401(self):
|
||||||
|
oauth = OAuth2Auth(token_url="/token", validator=simple_validator)
|
||||||
|
|
||||||
|
def setup(app: FastAPI):
|
||||||
|
@app.get("/me")
|
||||||
|
async def me(user=Security(oauth)):
|
||||||
|
return user
|
||||||
|
|
||||||
|
client = TestClient(_app(setup))
|
||||||
|
response = client.get("/me")
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_extract_no_header(self):
|
||||||
|
from starlette.requests import Request
|
||||||
|
|
||||||
|
auth = OAuth2Auth("/token", simple_validator)
|
||||||
|
scope = {"type": "http", "method": "GET", "path": "/", "headers": []}
|
||||||
|
request = Request(scope)
|
||||||
|
assert await auth.extract(request) is None
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_extract_empty_token(self):
|
||||||
|
from starlette.requests import Request
|
||||||
|
|
||||||
|
auth = OAuth2Auth("/token", simple_validator)
|
||||||
|
scope = {
|
||||||
|
"type": "http",
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/",
|
||||||
|
"headers": [(b"authorization", b"Bearer ")],
|
||||||
|
}
|
||||||
|
request = Request(scope)
|
||||||
|
assert await auth.extract(request) is None
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_extract_token(self):
|
||||||
|
from starlette.requests import Request
|
||||||
|
|
||||||
|
auth = OAuth2Auth("/token", simple_validator)
|
||||||
|
scope = {
|
||||||
|
"type": "http",
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/",
|
||||||
|
"headers": [(b"authorization", b"Bearer mytoken")],
|
||||||
|
}
|
||||||
|
request = Request(scope)
|
||||||
|
assert await auth.extract(request) == "mytoken"
|
||||||
|
|
||||||
|
def test_in_multi_auth(self):
|
||||||
|
"""OAuth2Auth.authenticate() is exercised when used inside MultiAuth."""
|
||||||
|
oauth = OAuth2Auth(token_url="/token", validator=simple_validator)
|
||||||
|
multi = MultiAuth(oauth)
|
||||||
|
|
||||||
|
def setup(app: FastAPI):
|
||||||
|
@app.get("/me")
|
||||||
|
async def me(user=Security(multi)):
|
||||||
|
return user
|
||||||
|
|
||||||
|
client = TestClient(_app(setup))
|
||||||
|
response = client.get("/me", headers={"Authorization": f"Bearer {VALID_TOKEN}"})
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"user": "alice"}
|
||||||
|
|
||||||
|
|
||||||
|
class TestMultiAuth:
|
||||||
|
def test_first_source_matches(self):
|
||||||
|
bearer = BearerTokenAuth(simple_validator)
|
||||||
|
cookie = CookieAuth("session", cookie_validator)
|
||||||
|
multi = MultiAuth(bearer, cookie)
|
||||||
|
|
||||||
|
def setup(app: FastAPI):
|
||||||
|
@app.get("/me")
|
||||||
|
async def me(user=Security(multi)):
|
||||||
|
return user
|
||||||
|
|
||||||
|
client = TestClient(_app(setup))
|
||||||
|
response = client.get("/me", headers={"Authorization": f"Bearer {VALID_TOKEN}"})
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"user": "alice"}
|
||||||
|
|
||||||
|
def test_second_source_matches_when_first_absent(self):
|
||||||
|
bearer = BearerTokenAuth(simple_validator)
|
||||||
|
cookie = CookieAuth("session", cookie_validator)
|
||||||
|
multi = MultiAuth(bearer, cookie)
|
||||||
|
|
||||||
|
def setup(app: FastAPI):
|
||||||
|
@app.get("/me")
|
||||||
|
async def me(user=Security(multi)):
|
||||||
|
return user
|
||||||
|
|
||||||
|
client = TestClient(_app(setup))
|
||||||
|
# No Authorization header — falls through to cookie
|
||||||
|
response = client.get("/me", cookies={"session": VALID_COOKIE})
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"session": VALID_COOKIE}
|
||||||
|
|
||||||
|
def test_no_source_matches_returns_401(self):
|
||||||
|
bearer = BearerTokenAuth(simple_validator)
|
||||||
|
cookie = CookieAuth("session", cookie_validator)
|
||||||
|
multi = MultiAuth(bearer, cookie)
|
||||||
|
|
||||||
|
def setup(app: FastAPI):
|
||||||
|
@app.get("/me")
|
||||||
|
async def me(user=Security(multi)):
|
||||||
|
return user
|
||||||
|
|
||||||
|
client = TestClient(_app(setup))
|
||||||
|
response = client.get("/me")
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
def test_invalid_credential_does_not_fallthrough(self):
|
||||||
|
"""If a credential is found but invalid, the next source is NOT tried."""
|
||||||
|
second_called: list[bool] = []
|
||||||
|
|
||||||
|
async def tracking_validator(credential: str) -> dict:
|
||||||
|
second_called.append(True)
|
||||||
|
return {"from": "second"}
|
||||||
|
|
||||||
|
bearer = BearerTokenAuth(simple_validator) # raises on wrong token
|
||||||
|
cookie = CookieAuth("session", tracking_validator)
|
||||||
|
multi = MultiAuth(bearer, cookie)
|
||||||
|
|
||||||
|
def setup(app: FastAPI):
|
||||||
|
@app.get("/me")
|
||||||
|
async def me(user=Security(multi)):
|
||||||
|
return user
|
||||||
|
|
||||||
|
client = TestClient(_app(setup))
|
||||||
|
# Bearer credential present but wrong — should NOT try cookie
|
||||||
|
response = client.get(
|
||||||
|
"/me",
|
||||||
|
headers={"Authorization": "Bearer wrong"},
|
||||||
|
cookies={"session": VALID_COOKIE},
|
||||||
|
)
|
||||||
|
assert response.status_code == 401
|
||||||
|
assert second_called == [] # cookie validator was never called
|
||||||
|
|
||||||
|
def test_prefix_routes_to_correct_source(self):
|
||||||
|
"""Prefix-based dispatch: only the matching source's validator is called."""
|
||||||
|
user_calls: list[str] = []
|
||||||
|
org_calls: list[str] = []
|
||||||
|
|
||||||
|
async def user_validator(credential: str) -> dict:
|
||||||
|
user_calls.append(credential)
|
||||||
|
return {"type": "user", "id": credential}
|
||||||
|
|
||||||
|
async def org_validator(credential: str) -> dict:
|
||||||
|
org_calls.append(credential)
|
||||||
|
return {"type": "org", "id": credential}
|
||||||
|
|
||||||
|
user_bearer = BearerTokenAuth(user_validator, prefix="user_")
|
||||||
|
org_bearer = BearerTokenAuth(org_validator, prefix="org_")
|
||||||
|
multi = MultiAuth(user_bearer, org_bearer)
|
||||||
|
|
||||||
|
def setup(app: FastAPI):
|
||||||
|
@app.get("/me")
|
||||||
|
async def me(user=Security(multi)):
|
||||||
|
return user
|
||||||
|
|
||||||
|
client = TestClient(_app(setup))
|
||||||
|
|
||||||
|
response = client.get("/me", headers={"Authorization": "Bearer user_alice"})
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"type": "user", "id": "user_alice"}
|
||||||
|
assert user_calls == ["user_alice"]
|
||||||
|
assert org_calls == []
|
||||||
|
|
||||||
|
user_calls.clear()
|
||||||
|
|
||||||
|
response = client.get("/me", headers={"Authorization": "Bearer org_acme"})
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"type": "org", "id": "org_acme"}
|
||||||
|
assert user_calls == []
|
||||||
|
assert org_calls == ["org_acme"]
|
||||||
|
|
||||||
|
def test_require_returns_new_multi_auth(self):
|
||||||
|
from fastapi_toolsets.security.multi import MultiAuth as MultiAuthClass
|
||||||
|
|
||||||
|
bearer = BearerTokenAuth(role_validator)
|
||||||
|
multi = MultiAuth(bearer)
|
||||||
|
derived = multi.require(role="admin")
|
||||||
|
assert isinstance(derived, MultiAuthClass)
|
||||||
|
assert derived is not multi
|
||||||
|
|
||||||
|
def test_require_forwards_kwargs_to_sources(self):
|
||||||
|
"""multi.require() propagates to all sources that support it."""
|
||||||
|
bearer = BearerTokenAuth(role_validator)
|
||||||
|
multi = MultiAuth(bearer)
|
||||||
|
|
||||||
|
def setup(app: FastAPI):
|
||||||
|
@app.get("/admin")
|
||||||
|
async def admin(user=Security(multi.require(role="admin"))):
|
||||||
|
return user
|
||||||
|
|
||||||
|
client = TestClient(_app(setup))
|
||||||
|
response = client.get(
|
||||||
|
"/admin", headers={"Authorization": f"Bearer {VALID_TOKEN}"}
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"user": "alice", "role": "admin"}
|
||||||
|
|
||||||
|
def test_require_skips_sources_without_require(self):
|
||||||
|
"""Sources without require() are passed through unchanged."""
|
||||||
|
header_auth = _HeaderAuth(secret="s3cr3t")
|
||||||
|
multi = MultiAuth(header_auth)
|
||||||
|
derived = multi.require(role="admin")
|
||||||
|
assert derived._sources[0] is header_auth
|
||||||
|
|
||||||
|
def test_require_does_not_mutate_original(self):
|
||||||
|
bearer = BearerTokenAuth(role_validator, role="user")
|
||||||
|
multi = MultiAuth(bearer)
|
||||||
|
multi.require(role="admin")
|
||||||
|
assert bearer._kwargs == {"role": "user"}
|
||||||
|
|
||||||
|
def test_require_mixed_sources(self):
|
||||||
|
"""require() applies to sources with require(), skips those without."""
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
|
bearer = BearerTokenAuth(role_validator)
|
||||||
|
header_auth = _HeaderAuth(secret="s3cr3t")
|
||||||
|
multi = MultiAuth(bearer, header_auth)
|
||||||
|
derived = multi.require(role="admin")
|
||||||
|
# bearer got require() applied, header_auth passed through
|
||||||
|
assert cast(BearerTokenAuth, derived._sources[0])._kwargs == {"role": "admin"}
|
||||||
|
assert derived._sources[1] is header_auth
|
||||||
|
|
||||||
|
|
||||||
|
class TestRequire:
|
||||||
|
def test_bearer_require_forwards_kwargs(self):
|
||||||
|
"""require() creates a new instance that passes merged kwargs to validator."""
|
||||||
|
bearer = BearerTokenAuth(role_validator)
|
||||||
|
|
||||||
|
def setup(app: FastAPI):
|
||||||
|
@app.get("/admin")
|
||||||
|
async def admin(user=Security(bearer.require(role="admin"))):
|
||||||
|
return user
|
||||||
|
|
||||||
|
client = TestClient(_app(setup))
|
||||||
|
response = client.get(
|
||||||
|
"/admin", headers={"Authorization": f"Bearer {VALID_TOKEN}"}
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"user": "alice", "role": "admin"}
|
||||||
|
|
||||||
|
def test_bearer_require_overrides_existing_kwarg(self):
|
||||||
|
"""require() kwargs override kwargs set at instantiation."""
|
||||||
|
bearer = BearerTokenAuth(role_validator, role="user")
|
||||||
|
|
||||||
|
def setup(app: FastAPI):
|
||||||
|
@app.get("/admin")
|
||||||
|
async def admin(user=Security(bearer.require(role="admin"))):
|
||||||
|
return user
|
||||||
|
|
||||||
|
client = TestClient(_app(setup))
|
||||||
|
response = client.get(
|
||||||
|
"/admin", headers={"Authorization": f"Bearer {VALID_TOKEN}"}
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["role"] == "admin"
|
||||||
|
|
||||||
|
def test_bearer_require_preserves_prefix(self):
|
||||||
|
"""require() keeps the prefix of the original instance."""
|
||||||
|
bearer = BearerTokenAuth(role_validator, prefix="user_")
|
||||||
|
derived = bearer.require(role="admin")
|
||||||
|
assert derived._prefix == "user_"
|
||||||
|
|
||||||
|
def test_bearer_require_does_not_mutate_original(self):
|
||||||
|
"""require() returns a new instance — original kwargs are unchanged."""
|
||||||
|
bearer = BearerTokenAuth(role_validator, role="user")
|
||||||
|
bearer.require(role="admin")
|
||||||
|
assert bearer._kwargs == {"role": "user"}
|
||||||
|
|
||||||
|
def test_cookie_require_forwards_kwargs(self):
|
||||||
|
async def scoped_validator(value: str, *, scope: str) -> dict:
|
||||||
|
if value != VALID_COOKIE:
|
||||||
|
raise UnauthorizedError()
|
||||||
|
return {"session": value, "scope": scope}
|
||||||
|
|
||||||
|
cookie = CookieAuth("session", scoped_validator)
|
||||||
|
|
||||||
|
def setup(app: FastAPI):
|
||||||
|
@app.get("/admin")
|
||||||
|
async def admin(user=Security(cookie.require(scope="admin"))):
|
||||||
|
return user
|
||||||
|
|
||||||
|
client = TestClient(_app(setup))
|
||||||
|
response = client.get("/admin", cookies={"session": VALID_COOKIE})
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"session": VALID_COOKIE, "scope": "admin"}
|
||||||
|
|
||||||
|
def test_cookie_require_preserves_name(self):
|
||||||
|
cookie = CookieAuth("session", cookie_validator)
|
||||||
|
derived = cookie.require(scope="admin")
|
||||||
|
assert derived._name == "session"
|
||||||
|
|
||||||
|
def test_oauth2_require_forwards_kwargs(self):
|
||||||
|
oauth = OAuth2Auth("/token", role_validator)
|
||||||
|
|
||||||
|
def setup(app: FastAPI):
|
||||||
|
@app.get("/admin")
|
||||||
|
async def admin(user=Security(oauth.require(role="admin"))):
|
||||||
|
return user
|
||||||
|
|
||||||
|
client = TestClient(_app(setup))
|
||||||
|
response = client.get(
|
||||||
|
"/admin", headers={"Authorization": f"Bearer {VALID_TOKEN}"}
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"user": "alice", "role": "admin"}
|
||||||
|
|
||||||
|
def test_oauth2_require_preserves_token_url(self):
|
||||||
|
oauth = OAuth2Auth("/token", simple_validator)
|
||||||
|
derived = oauth.require(role="admin")
|
||||||
|
assert derived._token_url == "/token"
|
||||||
|
|
||||||
|
def test_bearer_require_in_multi_auth(self):
|
||||||
|
"""require() instances work seamlessly inside MultiAuth."""
|
||||||
|
PREFIXED_TOKEN = f"user_{VALID_TOKEN}"
|
||||||
|
|
||||||
|
async def prefixed_role_validator(credential: str, *, role: str) -> dict:
|
||||||
|
if credential != PREFIXED_TOKEN:
|
||||||
|
raise UnauthorizedError()
|
||||||
|
return {"user": "alice", "role": role}
|
||||||
|
|
||||||
|
bearer = BearerTokenAuth(prefixed_role_validator, prefix="user_")
|
||||||
|
multi = MultiAuth(bearer.require(role="admin"))
|
||||||
|
|
||||||
|
def setup(app: FastAPI):
|
||||||
|
@app.get("/admin")
|
||||||
|
async def admin(user=Security(multi)):
|
||||||
|
return user
|
||||||
|
|
||||||
|
client = TestClient(_app(setup))
|
||||||
|
response = client.get(
|
||||||
|
"/admin", headers={"Authorization": f"Bearer {PREFIXED_TOKEN}"}
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"user": "alice", "role": "admin"}
|
||||||
|
|
||||||
|
|
||||||
|
class TestOpenIDAuth:
|
||||||
|
DISCOVERY_URL = "https://accounts.example.com/.well-known/openid-configuration"
|
||||||
|
|
||||||
|
def test_valid_token_returns_identity(self):
|
||||||
|
oidc = OpenIDAuth(self.DISCOVERY_URL, simple_validator)
|
||||||
|
|
||||||
|
def setup(app: FastAPI):
|
||||||
|
@app.get("/me")
|
||||||
|
async def me(user=Security(oidc)):
|
||||||
|
return user
|
||||||
|
|
||||||
|
client = TestClient(_app(setup))
|
||||||
|
response = client.get("/me", headers={"Authorization": f"Bearer {VALID_TOKEN}"})
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"user": "alice"}
|
||||||
|
|
||||||
|
def test_missing_token_returns_401(self):
|
||||||
|
oidc = OpenIDAuth(self.DISCOVERY_URL, simple_validator)
|
||||||
|
|
||||||
|
def setup(app: FastAPI):
|
||||||
|
@app.get("/me")
|
||||||
|
async def me(user=Security(oidc)):
|
||||||
|
return user
|
||||||
|
|
||||||
|
client = TestClient(_app(setup))
|
||||||
|
response = client.get("/me")
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
def test_invalid_token_returns_401(self):
|
||||||
|
oidc = OpenIDAuth(self.DISCOVERY_URL, simple_validator)
|
||||||
|
|
||||||
|
def setup(app: FastAPI):
|
||||||
|
@app.get("/me")
|
||||||
|
async def me(user=Security(oidc)):
|
||||||
|
return user
|
||||||
|
|
||||||
|
client = TestClient(_app(setup))
|
||||||
|
response = client.get("/me", headers={"Authorization": "Bearer wrong"})
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
def test_kwargs_forwarded_to_validator(self):
|
||||||
|
oidc = OpenIDAuth(self.DISCOVERY_URL, role_validator, role="admin")
|
||||||
|
|
||||||
|
def setup(app: FastAPI):
|
||||||
|
@app.get("/me")
|
||||||
|
async def me(user=Security(oidc)):
|
||||||
|
return user
|
||||||
|
|
||||||
|
client = TestClient(_app(setup))
|
||||||
|
response = client.get("/me", headers={"Authorization": f"Bearer {VALID_TOKEN}"})
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"user": "alice", "role": "admin"}
|
||||||
|
|
||||||
|
def test_require_forwards_kwargs(self):
|
||||||
|
oidc = OpenIDAuth(self.DISCOVERY_URL, role_validator)
|
||||||
|
|
||||||
|
def setup(app: FastAPI):
|
||||||
|
@app.get("/admin")
|
||||||
|
async def admin(user=Security(oidc.require(role="admin"))):
|
||||||
|
return user
|
||||||
|
|
||||||
|
client = TestClient(_app(setup))
|
||||||
|
response = client.get(
|
||||||
|
"/admin", headers={"Authorization": f"Bearer {VALID_TOKEN}"}
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"user": "alice", "role": "admin"}
|
||||||
|
|
||||||
|
def test_require_preserves_discovery_url(self):
|
||||||
|
oidc = OpenIDAuth(self.DISCOVERY_URL, simple_validator)
|
||||||
|
derived = oidc.require(role="admin")
|
||||||
|
assert derived._openid_connect_url == self.DISCOVERY_URL
|
||||||
|
|
||||||
|
def test_require_does_not_mutate_original(self):
|
||||||
|
oidc = OpenIDAuth(self.DISCOVERY_URL, role_validator, role="user")
|
||||||
|
oidc.require(role="admin")
|
||||||
|
assert oidc._kwargs == {"role": "user"}
|
||||||
|
|
||||||
|
def test_is_auth_source(self):
|
||||||
|
oidc = OpenIDAuth(self.DISCOVERY_URL, simple_validator)
|
||||||
|
assert isinstance(oidc, AuthSource)
|
||||||
|
|
||||||
|
def test_in_multi_auth(self):
|
||||||
|
"""OpenIDAuth works seamlessly inside MultiAuth."""
|
||||||
|
oidc = OpenIDAuth(self.DISCOVERY_URL, simple_validator)
|
||||||
|
multi = MultiAuth(oidc)
|
||||||
|
|
||||||
|
def setup(app: FastAPI):
|
||||||
|
@app.get("/me")
|
||||||
|
async def me(user=Security(multi)):
|
||||||
|
return user
|
||||||
|
|
||||||
|
client = TestClient(_app(setup))
|
||||||
|
response = client.get("/me", headers={"Authorization": f"Bearer {VALID_TOKEN}"})
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"user": "alice"}
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_extract_no_header(self):
|
||||||
|
from starlette.requests import Request
|
||||||
|
|
||||||
|
oidc = OpenIDAuth(self.DISCOVERY_URL, simple_validator)
|
||||||
|
scope = {"type": "http", "method": "GET", "path": "/", "headers": []}
|
||||||
|
request = Request(scope)
|
||||||
|
assert await oidc.extract(request) is None
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_extract_empty_token(self):
|
||||||
|
from starlette.requests import Request
|
||||||
|
|
||||||
|
oidc = OpenIDAuth(self.DISCOVERY_URL, simple_validator)
|
||||||
|
scope = {
|
||||||
|
"type": "http",
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/",
|
||||||
|
"headers": [(b"authorization", b"Bearer ")],
|
||||||
|
}
|
||||||
|
request = Request(scope)
|
||||||
|
assert await oidc.extract(request) is None
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_extract_token(self):
|
||||||
|
from starlette.requests import Request
|
||||||
|
|
||||||
|
oidc = OpenIDAuth(self.DISCOVERY_URL, simple_validator)
|
||||||
|
scope = {
|
||||||
|
"type": "http",
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/",
|
||||||
|
"headers": [(b"authorization", b"Bearer mytoken")],
|
||||||
|
}
|
||||||
|
request = Request(scope)
|
||||||
|
assert await oidc.extract(request) == "mytoken"
|
||||||
|
|
||||||
|
|
||||||
|
# Minimal concrete subclass used only in tests below.
|
||||||
|
class _HeaderAuth(AuthSource):
|
||||||
|
"""Reads a custom X-Token header — no FastAPI security scheme."""
|
||||||
|
|
||||||
|
def __init__(self, secret: str) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self._secret = secret
|
||||||
|
|
||||||
|
async def extract(self, request) -> str | None:
|
||||||
|
return request.headers.get("X-Token") or None
|
||||||
|
|
||||||
|
async def authenticate(self, credential: str) -> dict:
|
||||||
|
if credential != self._secret:
|
||||||
|
raise UnauthorizedError()
|
||||||
|
return {"token": credential}
|
||||||
|
|
||||||
|
|
||||||
|
class TestAuthSource:
|
||||||
|
def test_cannot_instantiate_abstract_class(self):
|
||||||
|
with pytest.raises(TypeError):
|
||||||
|
AuthSource()
|
||||||
|
|
||||||
|
def test_builtin_classes_are_auth_sources(self):
|
||||||
|
bearer = BearerTokenAuth(simple_validator)
|
||||||
|
cookie = CookieAuth("session", cookie_validator)
|
||||||
|
oauth = OAuth2Auth("/token", simple_validator)
|
||||||
|
oidc = OpenIDAuth(
|
||||||
|
"https://example.com/.well-known/openid-configuration", simple_validator
|
||||||
|
)
|
||||||
|
assert isinstance(bearer, AuthSource)
|
||||||
|
assert isinstance(cookie, AuthSource)
|
||||||
|
assert isinstance(oauth, AuthSource)
|
||||||
|
assert isinstance(oidc, AuthSource)
|
||||||
|
|
||||||
|
def test_custom_source_standalone_valid(self):
|
||||||
|
"""Default __call__ wires extract + authenticate via Request injection."""
|
||||||
|
auth = _HeaderAuth(secret="s3cr3t")
|
||||||
|
|
||||||
|
def setup(app: FastAPI):
|
||||||
|
@app.get("/me")
|
||||||
|
async def me(user=Security(auth)):
|
||||||
|
return user
|
||||||
|
|
||||||
|
client = TestClient(_app(setup))
|
||||||
|
response = client.get("/me", headers={"X-Token": "s3cr3t"})
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"token": "s3cr3t"}
|
||||||
|
|
||||||
|
def test_custom_source_standalone_missing_credential(self):
|
||||||
|
auth = _HeaderAuth(secret="s3cr3t")
|
||||||
|
|
||||||
|
def setup(app: FastAPI):
|
||||||
|
@app.get("/me")
|
||||||
|
async def me(user=Security(auth)):
|
||||||
|
return user
|
||||||
|
|
||||||
|
client = TestClient(_app(setup))
|
||||||
|
response = client.get("/me") # no X-Token header
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
def test_custom_source_standalone_invalid_credential(self):
|
||||||
|
auth = _HeaderAuth(secret="s3cr3t")
|
||||||
|
|
||||||
|
def setup(app: FastAPI):
|
||||||
|
@app.get("/me")
|
||||||
|
async def me(user=Security(auth)):
|
||||||
|
return user
|
||||||
|
|
||||||
|
client = TestClient(_app(setup))
|
||||||
|
response = client.get("/me", headers={"X-Token": "wrong"})
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
def test_custom_source_in_multi_auth(self):
|
||||||
|
"""Custom AuthSource works transparently inside MultiAuth."""
|
||||||
|
header_auth = _HeaderAuth(secret="s3cr3t")
|
||||||
|
bearer = BearerTokenAuth(simple_validator)
|
||||||
|
multi = MultiAuth(bearer, header_auth)
|
||||||
|
|
||||||
|
def setup(app: FastAPI):
|
||||||
|
@app.get("/me")
|
||||||
|
async def me(user=Security(multi)):
|
||||||
|
return user
|
||||||
|
|
||||||
|
client = TestClient(_app(setup))
|
||||||
|
|
||||||
|
# Bearer matches first
|
||||||
|
response = client.get("/me", headers={"Authorization": f"Bearer {VALID_TOKEN}"})
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"user": "alice"}
|
||||||
|
|
||||||
|
# No bearer → falls through to custom header source
|
||||||
|
response = client.get("/me", headers={"X-Token": "s3cr3t"})
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"token": "s3cr3t"}
|
||||||
@@ -251,7 +251,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fastapi-toolsets"
|
name = "fastapi-toolsets"
|
||||||
version = "2.0.0"
|
version = "1.3.0"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "asyncpg" },
|
{ name = "asyncpg" },
|
||||||
|
|||||||
@@ -77,10 +77,6 @@ md_in_html = {}
|
|||||||
"pymdownx.tasklist" = {custom_checkbox = true}
|
"pymdownx.tasklist" = {custom_checkbox = true}
|
||||||
"pymdownx.tilde" = {}
|
"pymdownx.tilde" = {}
|
||||||
|
|
||||||
[project.markdown_extensions.pymdownx.emoji]
|
|
||||||
emoji_index = "zensical.extensions.emoji.twemoji"
|
|
||||||
emoji_generator = "zensical.extensions.emoji.to_svg"
|
|
||||||
|
|
||||||
[project.markdown_extensions."pymdownx.highlight"]
|
[project.markdown_extensions."pymdownx.highlight"]
|
||||||
anchor_linenums = true
|
anchor_linenums = true
|
||||||
line_spans = "__span"
|
line_spans = "__span"
|
||||||
@@ -99,49 +95,3 @@ permalink = true
|
|||||||
[project.markdown_extensions."pymdownx.snippets"]
|
[project.markdown_extensions."pymdownx.snippets"]
|
||||||
base_path = ["."]
|
base_path = ["."]
|
||||||
check_paths = true
|
check_paths = true
|
||||||
|
|
||||||
[[project.nav]]
|
|
||||||
Home = "index.md"
|
|
||||||
|
|
||||||
[[project.nav]]
|
|
||||||
Modules = [
|
|
||||||
{CLI = "module/cli.md"},
|
|
||||||
{CRUD = "module/crud.md"},
|
|
||||||
{Database = "module/db.md"},
|
|
||||||
{Dependencies = "module/dependencies.md"},
|
|
||||||
{Exceptions = "module/exceptions.md"},
|
|
||||||
{Fixtures = "module/fixtures.md"},
|
|
||||||
{Logger = "module/logger.md"},
|
|
||||||
{Metrics = "module/metrics.md"},
|
|
||||||
{Models = "module/models.md"},
|
|
||||||
{Pytest = "module/pytest.md"},
|
|
||||||
{Schemas = "module/schemas.md"},
|
|
||||||
]
|
|
||||||
|
|
||||||
[[project.nav]]
|
|
||||||
Reference = [
|
|
||||||
{CLI = "reference/cli.md"},
|
|
||||||
{CRUD = "reference/crud.md"},
|
|
||||||
{Database = "reference/db.md"},
|
|
||||||
{Dependencies = "reference/dependencies.md"},
|
|
||||||
{Exceptions = "reference/exceptions.md"},
|
|
||||||
{Fixtures = "reference/fixtures.md"},
|
|
||||||
{Logger = "reference/logger.md"},
|
|
||||||
{Metrics = "reference/metrics.md"},
|
|
||||||
{Models = "reference/models.md"},
|
|
||||||
{Pytest = "reference/pytest.md"},
|
|
||||||
{Schemas = "reference/schemas.md"},
|
|
||||||
]
|
|
||||||
|
|
||||||
[[project.nav]]
|
|
||||||
Examples = [
|
|
||||||
{"Pagination & Search" = "examples/pagination-search.md"},
|
|
||||||
]
|
|
||||||
|
|
||||||
[[project.nav]]
|
|
||||||
Migration = [
|
|
||||||
{"v2.0" = "migration/v2.md"},
|
|
||||||
]
|
|
||||||
|
|
||||||
[[project.nav]]
|
|
||||||
"Changelog ↗" = "https://github.com/d3vyce/fastapi-toolsets/releases"
|
|
||||||
|
|||||||
Reference in New Issue
Block a user