diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..1711f4f --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,56 @@ +name: Deploy Docs + +on: + push: + branches: [master] + paths: + - "docs/**" + - "mkdocs.yml" + - ".github/workflows/docs.yml" + pull_request: + paths: + - "docs/**" + - "mkdocs.yml" + - ".github/workflows/docs.yml" + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: "pages" + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install docs dependencies + run: | + pip install -e ".[docs]" + + - name: Build site + run: mkdocs build --strict --clean + + - uses: actions/upload-pages-artifact@v3 + with: + path: site + + deploy: + needs: build + if: github.event_name == 'push' + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..45ddf0a --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +site/ diff --git a/README.md b/README.md index f769efc..7b0887f 100644 --- a/README.md +++ b/README.md @@ -4,12 +4,15 @@ Django REST Framework-style ViewSets for FastAPI — auto-generate CRUD endpoint [![PyPI version](https://badge.fury.io/py/fastapi-viewsets.svg)](https://pypi.org/project/fastapi-viewsets/) [![Python versions](https://img.shields.io/pypi/pyversions/fastapi-viewsets.svg)](https://pypi.org/project/fastapi-viewsets/) -[![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](https://github.com/svalench/fastapi_viewsets/blob/main/LICENSE) -[![CI](https://github.com/svalench/fastapi_viewsets/actions/workflows/test.yml/badge.svg?branch=main)](https://github.com/svalench/fastapi_viewsets/actions/workflows/test.yml) +[![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](https://github.com/svalench/fastapi_viewsets/blob/master/LICENSE) +[![CI](https://github.com/svalench/fastapi_viewsets/actions/workflows/test.yml/badge.svg?branch=master)](https://github.com/svalench/fastapi_viewsets/actions/workflows/test.yml) [![codecov](https://codecov.io/gh/svalench/fastapi_viewsets/graph/badge.svg)](https://codecov.io/gh/svalench/fastapi_viewsets) [![Downloads/month](https://static.pepy.tech/badge/fastapi-viewsets/month)](https://pepy.tech/project/fastapi-viewsets) [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](https://github.com/svalench/fastapi_viewsets/pulls) +[![Docs](https://img.shields.io/badge/docs-svalench.github.io-blue.svg)](https://svalench.github.io/fastapi_viewsets/) + +**Documentation:** [https://svalench.github.io/fastapi_viewsets/](https://svalench.github.io/fastapi_viewsets/) ## Why fastapi-viewsets @@ -59,7 +62,7 @@ you need, and the URL shape for **PostgreSQL**, **MySQL**, and | --- | --- | --- | --- | | **SQLAlchemy (sync)** | `psycopg[binary]` or `psycopg2-binary` | `pymysql` or `mysqlclient` | `pyodbc` + ODBC Driver 17/18 | | **SQLAlchemy (async)** | `asyncpg` | `aiomysql` or `asyncmy` | `aioodbc` + ODBC Driver 17/18 | -| **Tortoise ORM** | `asyncpg` (built-in) | `aiomysql` (built-in) | Not supported by Tortoise | +| **Tortoise ORM** | `asyncpg` (included in `[tortoise]` extra) | `aiomysql` (install separately) | Not supported by Tortoise | | **Peewee** | `psycopg2-binary` | `pymysql` or `mysqlclient` | Not supported by this adapter | > The `SQLAlchemyAdapter` auto-converts a sync URL to its async @@ -142,8 +145,9 @@ TORTOISE_APP_LABEL=models ``` ```python +from contextlib import asynccontextmanager + from fastapi import FastAPI -from tortoise import Tortoise from fastapi_viewsets import AsyncBaseViewset from fastapi_viewsets.orm.factory import ORMFactory @@ -152,24 +156,19 @@ app = FastAPI() adapter = ORMFactory.get_default_adapter() # built from the env vars above -@app.on_event("startup") -async def _init_tortoise() -> None: - """Open the Tortoise connection pool and create schema if needed. +@asynccontextmanager +async def lifespan(app: FastAPI): + """Open the Tortoise connection pool at startup. - The adapter also initializes Tortoise lazily on first DB call; - doing it here gives you control over schema creation. + The adapter also initialises Tortoise lazily on the first DB call; + calling ``initialize()`` here gives you control over schema creation + and avoids a cold-start penalty on the first request. """ - await Tortoise.init( - db_url=adapter.database_url, - modules={adapter.app_label: adapter.models}, - ) - await Tortoise.generate_schemas(safe=True) - + await adapter.initialize(generate_schemas=True) + yield + await adapter.close() -@app.on_event("shutdown") -async def _close_tortoise() -> None: - """Close the Tortoise connection pool.""" - await Tortoise.close_connections() +app = FastAPI(lifespan=lifespan) # Define your Tortoise models in app/models.py and pass them to AsyncBaseViewset. @@ -187,6 +186,10 @@ async def _close_tortoise() -> None: # app.include_router(items) ``` +> **Note:** `generate_schemas=True` is convenient for development. In +> production, use [Aerich](https://github.com/tortoise/aerich) or another +> migration tool instead of auto-generating schemas at startup. +> > **MSSQL is not supported by Tortoise ORM.** Use SQLAlchemy with > `aioodbc` for SQL Server. @@ -265,6 +268,7 @@ Save as `main.py` in an empty folder and run `python main.py` or `uvicorn main:a from fastapi import FastAPI from pydantic import BaseModel, ConfigDict from sqlalchemy import Column, Integer, String +from typing import Optional from fastapi_viewsets import BaseViewset from fastapi_viewsets.db_conf import Base, engine, get_session @@ -284,7 +288,7 @@ class ItemSchema(BaseModel): """Pydantic model for request and response bodies.""" model_config = ConfigDict(from_attributes=True) - id: int | None = None + id: Optional[int] = None name: str @@ -374,9 +378,12 @@ package auto-converts `sqlite://` to `sqlite+aiosqlite://`, `postgresql://` to `postgresql+asyncpg://`, etc. ```python +from contextlib import asynccontextmanager + from fastapi import FastAPI from pydantic import BaseModel, ConfigDict from sqlalchemy import Column, Integer, String +from typing import Optional from fastapi_viewsets import AsyncBaseViewset from fastapi_viewsets.db_conf import ( @@ -385,7 +392,16 @@ from fastapi_viewsets.db_conf import ( get_async_session, ) -app = FastAPI() + +@asynccontextmanager +async def lifespan(app: FastAPI): + """Create tables once on startup using the async engine.""" + async with async_engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + yield + + +app = FastAPI(lifespan=lifespan) class Item(Base): @@ -400,17 +416,10 @@ class ItemSchema(BaseModel): """Pydantic v2 schema reused as request and response model.""" model_config = ConfigDict(from_attributes=True) - id: int | None = None + id: Optional[int] = None name: str -@app.on_event("startup") -async def _create_tables() -> None: - """Create tables once on startup using the async engine.""" - async with async_engine.begin() as conn: - await conn.run_sync(Base.metadata.create_all) - - items = AsyncBaseViewset( endpoint="/items", model=Item, @@ -575,6 +584,7 @@ from fastapi import FastAPI from fastapi.security import OAuth2PasswordBearer from pydantic import BaseModel, ConfigDict from sqlalchemy import Column, Integer, String +from typing import Optional from fastapi_viewsets import BaseViewset from fastapi_viewsets.db_conf import Base, engine, get_session @@ -593,7 +603,7 @@ class ItemSchema(BaseModel): """Pydantic schema for Item payloads and responses.""" model_config = ConfigDict(from_attributes=True) - id: int | None = None + id: Optional[int] = None name: str diff --git a/docs/api-reference.md b/docs/api-reference.md new file mode 100644 index 0000000..05abf7e --- /dev/null +++ b/docs/api-reference.md @@ -0,0 +1,311 @@ +# API Reference + +## `BaseViewset` + +```python +from fastapi_viewsets import BaseViewset +``` + +Synchronous CRUD viewset. Subclasses `APIRouter` and `_RegisterMixin`. Provides `LIST`, `GET`, `POST`, `PUT`, `PATCH`, and `DELETE` endpoints generated from an ORM model and a Pydantic response model. + +### Constructor + +```python +BaseViewset( + *, + allowed_methods: Optional[List[str]] = None, + endpoint: Optional[str] = None, + model: Optional[Type[ModelType]] = None, + db_session: Optional[Callable[[], Any]] = None, + response_model: Optional[Type[BaseModel]] = None, + orm_adapter: Optional[BaseORMAdapter] = None, + **kwargs, # forwarded to APIRouter +) +``` + +| Parameter | Type | Default | Description | +| --- | --- | --- | --- | +| `allowed_methods` | `Optional[List[str]]` | `None` | Override of `ALLOWED_METHODS` | +| `endpoint` | `Optional[str]` | `None` | Base endpoint path, e.g. `"/user"` | +| `model` | `Optional[Type]` | `None` | ORM model class | +| `db_session` | `Optional[Callable]` | `None` | Database session factory function | +| `response_model` | `Optional[Type[BaseModel]]` | `None` | Pydantic schema for request/response bodies | +| `orm_adapter` | `Optional[BaseORMAdapter]` | `None` | ORM adapter; resolved from config when omitted | +| `**kwargs` | — | — | Forwarded to `fastapi.APIRouter` (e.g. `tags`, `prefix`, `dependencies`) | + +### CRUD handlers + +#### `list()` + +```python +def list( + self, + limit: Optional[int] = 10, + offset: Optional[int] = 0, + search: Optional[str] = None, + token: str = Depends(_noop_dependency), +) -> List[ResponseModelType] +``` + +List items with `limit`/`offset` pagination. Returns a list of `response_model` instances. + +!!! warning "`search` parameter is reserved" + + The `search` parameter is accepted by `list()` and appears in the + OpenAPI schema, but ORM adapters currently ignore it. Server-side + search is planned for v1.4. Until then, override `list()` in a + subclass to implement filtering — see + [Pagination & Filtering](pagination-filtering.md). + +#### `get_element()` + +```python +def get_element( + self, + id: Union[int, str], + token: str = Depends(_noop_dependency), +) -> ResponseModelType +``` + +Retrieve a single item by ID. Raises `404` if `id` is empty or `None`. + +#### `create_element()` + +```python +def create_element( + self, + item: ResponseModelType = Body(...), + token: str = Depends(_noop_dependency), +) -> ResponseModelType +``` + +Create a new item from the request body. + +#### `update_element()` + +```python +def update_element( + self, + id: Union[int, str], + item: ResponseModelType = Body(...), + token: str = Depends(_noop_dependency), + partial: bool = False, +) -> ResponseModelType +``` + +Update an existing item. `partial=True` (used by `PATCH`) only writes fields the client explicitly set, preserving correct PATCH semantics under Pydantic v2. + +#### `delete_element()` + +```python +def delete_element( + self, + id: Union[int, str], + token: str = Depends(_noop_dependency), +) -> Dict[str, Union[bool, str]] +``` + +Delete an item by ID. Returns `{"status": True, "text": "successfully deleted"}` or `{"status": False, "text": "deletion failed"}`. + +--- + +## `AsyncBaseViewset` + +```python +from fastapi_viewsets import AsyncBaseViewset +``` + +Asynchronous CRUD viewset. Mirrors `BaseViewset` but every CRUD handler is `async`, backed by an async-capable ORM adapter (SQLAlchemy `AsyncSession` or Tortoise ORM). + +### Constructor + +Same parameters as `BaseViewset`, but `db_session` should be an async session factory (`Callable[[], AsyncSession]`). + +### CRUD handlers + +Identical signatures to `BaseViewset`, but `async def`: + +- `async def list(...)` +- `async def get_element(...)` +- `async def create_element(...)` +- `async def update_element(...)` +- `async def delete_element(...)` + +--- + +## `register()` + +```python +def register( + self, + methods: Optional[List[str]] = None, + oauth_protect: Optional[OAuth2PasswordBearer] = None, + protected_methods: Optional[List[str]] = None, +) -> None +``` + +Register CRUD endpoints on the router. + +| Parameter | Type | Default | Description | +| --- | --- | --- | --- | +| `methods` | `Optional[List[str]]` | `None` (all) | Logical methods to register | +| `oauth_protect` | `Optional[OAuth2PasswordBearer]` | `None` | OAuth2 dependency for protected operations | +| `protected_methods` | `Optional[List[str]]` | `None` | Subset of `methods` requiring the bearer token | + +### Allowed methods + +```python +ALLOWED_METHODS = ["LIST", "POST", "GET", "PUT", "PATCH", "DELETE"] +``` + +### Method-to-route mapping + +| Logical method | HTTP method | Path | Handler | Response | +| --- | --- | --- | --- | --- | +| `LIST` | `GET` | `/` | `list` | `List[response_model]` | +| `GET` | `GET` | `/{id}` | `get_element` | `response_model` | +| `POST` | `POST` | `/` | `create_element` | `response_model` | +| `PUT` | `PUT` | `/{id}` | `update_element` (`partial=False`) | `response_model` | +| `PATCH` | `PATCH` | `/{id}` | `update_element` (`partial=True`) | `response_model` | +| `DELETE` | `DELETE` | `/{id}` | `delete_element` | `None` (raw dict) | + +--- + +## `ORMFactory` + +```python +from fastapi_viewsets.orm.factory import ORMFactory +``` + +Central entry point for adapter resolution. + +### Methods + +| Method | Returns | Description | +| --- | --- | --- | +| `get_default_adapter()` | `BaseORMAdapter` | Returns the cached singleton adapter for the current `ORM_TYPE` | +| `create_adapter(orm_type, config)` | `BaseORMAdapter` | Creates a new adapter instance from a dict config | +| `register_adapter(orm_type, adapter_class)` | `None` | Registers a custom adapter class (e.g. for a new ORM) | +| `get_adapter_from_env()` | `BaseORMAdapter` | Builds an adapter from environment variables (used internally by `get_default_adapter`) | +| `reset_default_adapter()` | `None` | Clears the cached singleton; the next `get_default_adapter()` call rebuilds from env. Useful in tests and hot-reload scenarios. | + +--- + +## `BaseORMAdapter` + +```python +from fastapi_viewsets.orm.base import BaseORMAdapter +``` + +Abstract base class for ORM adapters. All adapters (SQLAlchemy, Tortoise, Peewee) implement this interface. + +### Abstract methods + +| Method | Sync/Async | Description | +| --- | --- | --- | +| `get_session()` | Sync | Get a synchronous database session | +| `get_async_session()` | Sync | Get an async database session | +| `get_base()` | Sync | Get the base class for ORM models | +| `get_list_queryset(model, db_session, limit, offset, select_related, prefetch_related)` | Sync | Get a paginated list of model instances | +| `get_list_queryset_async(model, db_session, limit, offset, select_related, prefetch_related)` | Async | Async version of `get_list_queryset` | +| `get_element_by_id(model, db_session, id, select_related, prefetch_related)` | Sync | Get a single element by ID | +| `get_element_by_id_async(model, db_session, id, select_related, prefetch_related)` | Async | Async version of `get_element_by_id` | +| `create_element(model, db_session, data)` | Sync | Create a new element | +| `create_element_async(model, db_session, data)` | Async | Async version of `create_element` | +| `update_element(model, db_session, id, data, partial)` | Sync | Update an element (`partial=True` for PATCH) | +| `update_element_async(model, db_session, id, data, partial)` | Async | Async version of `update_element` | +| `delete_element(model, db_session, id)` | Sync | Delete an element by ID | +| `delete_element_async(model, db_session, id)` | Async | Async version of `delete_element` | +| `get_model_columns(model)` | Sync | Get column information for a model | + +--- + +## `db_conf` module + +```python +from fastapi_viewsets.db_conf import ( + ORM_TYPE, + SQLALCHEMY_DATABASE_URL, + get_orm_adapter, + # Lazy SQLAlchemy globals: + engine, + Base, + SessionLocal, + db_session, + get_session, + async_engine, + AsyncSessionLocal, + get_async_session, +) +``` + +### Environment variables + +| Variable | Default | Description | +| --- | --- | --- | +| `ORM_TYPE` | `sqlalchemy` | Active ORM: `sqlalchemy`, `tortoise`, or `peewee` | +| `DATABASE_URL` | `sqlite:////base.db` | Generic database URL (fallback) | +| `SQLALCHEMY_DATABASE_URL` | — | SQLAlchemy-specific URL | +| `SQLALCHEMY_ASYNC_DATABASE_URL` | Auto-derived | Explicit async URL (overrides auto-conversion) | +| `TORTOISE_DATABASE_URL` | — | Tortoise database URL | +| `TORTOISE_MODELS` | — | JSON list of model modules, e.g. `["app.models"]` | +| `TORTOISE_APP_LABEL` | `models` | Tortoise app label | +| `PEEWEE_DATABASE_URL` | — | Peewee database URL | + +### Lazy resolution + +SQLAlchemy globals (`engine`, `Base`, `get_session`, `get_async_session`, etc.) are resolved lazily on first access. Importing the package does not create engines unless they are needed, and works without async drivers installed. + +--- + +## Serializer utilities + +```python +from fastapi_viewsets.serializer_utils import get_select_related, get_prefetch_related +``` + +These helpers read eager-loading configuration from a Pydantic schema's inner `RelatedConfig` class. They are used internally by `get_list_queryset` and `get_element_by_id` (sync and async) when a `response_model` is provided. + +| Function | Returns | Description | +| --- | --- | --- | +| `get_select_related(response_model)` | `List[str]` | Reads `RelatedConfig.select_related` (FK / many-to-one relations) | +| `get_prefetch_related(response_model)` | `List[str]` | Reads `RelatedConfig.prefetch_related` (collections / M2M relations) | + +See [Eager Loading](eager-loading.md) for usage examples. + +--- + +## Internal auth placeholder + +The `token` parameter on every CRUD handler defaults to `Depends(_noop_dependency)`, where `_noop_dependency` is a private function that returns `None`. It is overridden when `oauth_protect` is passed to `register()`. The backward-compatible alias `butle` also points to this function. + +You should not import `_noop_dependency` directly. If you override a handler and want to preserve the optional-auth behaviour, use `Depends(lambda: None)` or your own no-op dependency. + +--- + +## Constants + +```python +from fastapi_viewsets.constants import ALLOWED_METHODS, MAP_METHODS +``` + +### `ALLOWED_METHODS` + +```python +ALLOWED_METHODS = ["LIST", "POST", "GET", "PUT", "PATCH", "DELETE"] +``` + +### `MAP_METHODS` + +Maps logical method names to their route specifications: + +```python +MAP_METHODS = { + "GET": {"method": "get_element", "http_method": "GET", "path": "/{id}", "is_list": False}, + "POST": {"method": "create_element", "http_method": "POST", "path": "", "is_list": False}, + "PUT": {"method": "update_element", "http_method": "PUT", "path": "/{id}", "is_list": False}, + "PATCH": {"method": "update_element", "http_method": "PATCH", "path": "/{id}", "is_list": False}, + "DELETE": {"method": "delete_element", "http_method": "DELETE", "path": "/{id}", "is_list": False}, + "LIST": {"method": "list", "http_method": "GET", "path": "", "is_list": True}, +} +``` diff --git a/docs/assets/custom.css b/docs/assets/custom.css new file mode 100644 index 0000000..09aadbe --- /dev/null +++ b/docs/assets/custom.css @@ -0,0 +1,25 @@ +/* fastapi-viewsets docs custom styles */ + +.md-header__title { + font-weight: 700; +} + +.md-typeset__table td:not(:last-child), +.md-typeset__table th:not(:last-child) { + padding-right: 1.2rem; +} + +.md-typeset .grid.cards > ul > li { + border: 1px solid var(--md-default-fg-color--lightest); + border-radius: 0.4rem; + transition: border-color 0.25s, box-shadow 0.25s; +} + +.md-typeset .grid.cards > ul > li:hover { + border-color: var(--md-accent-fg-color); + box-shadow: 0 0 0.2rem var(--md-accent-fg-color); +} + +.md-typeset code { + font-size: 0.85em; +} diff --git a/docs/authentication.md b/docs/authentication.md new file mode 100644 index 0000000..96b0ce9 --- /dev/null +++ b/docs/authentication.md @@ -0,0 +1,129 @@ +# Authentication + +`register()` accepts an `OAuth2PasswordBearer` instance plus a list of logical operations that require a bearer token. + +## How it works + +```python +from fastapi import FastAPI +from fastapi.security import OAuth2PasswordBearer +from pydantic import BaseModel, ConfigDict +from sqlalchemy import Column, Integer, String +from typing import Optional +from fastapi_viewsets import BaseViewset +from fastapi_viewsets.db_conf import Base, engine, get_session + +app = FastAPI() +oauth2 = OAuth2PasswordBearer(tokenUrl="/token") + +class Item(Base): + """SQLAlchemy model for OAuth2-protected writes.""" + + __tablename__ = "items_oauth" + id = Column(Integer, primary_key=True) + name = Column(String(255), nullable=False) + + +class ItemSchema(BaseModel): + """Pydantic schema for Item payloads and responses.""" + + model_config = ConfigDict(from_attributes=True) + id: Optional[int] = None + name: str + + +Base.metadata.create_all(bind=engine) +router = BaseViewset( + endpoint="/items", + model=Item, + response_model=ItemSchema, + db_session=get_session, + tags=["items"], +) +router.register( + methods=["LIST", "GET", "POST", "PATCH", "DELETE"], + oauth_protect=oauth2, + protected_methods=["POST", "PATCH", "DELETE"], +) +app.include_router(router) +``` + +## Parameters + +| Parameter | Type | Default | Description | +| --- | --- | --- | --- | +| `methods` | `Optional[List[str]]` | `None` (all) | Logical methods to register: `LIST`, `GET`, `POST`, `PUT`, `PATCH`, `DELETE` | +| `oauth_protect` | `Optional[OAuth2PasswordBearer]` | `None` | OAuth2 dependency instance | +| `protected_methods` | `Optional[List[str]]` | `None` | Subset of `methods` that require the bearer token. Ignored if `oauth_protect` is `None`. | + +## How protection is applied internally + +When a method is in `protected_methods` and `oauth_protect` is provided: + +1. The handler is wrapped with `functools.partial(handler, token=Depends(oauth_protect))`. +2. FastAPI injects the `OAuth2PasswordBearer` dependency, which requires the `Authorization: Bearer ` header on protected methods. +3. If the header is missing, FastAPI returns `401 Unauthorized` automatically. +4. Token validation and authorization logic (e.g. decoding JWT, checking scopes) is your responsibility — implement it in a dependency or inside the handler override. + +Methods not in `protected_methods` remain publicly accessible. This lets you, for example, allow anonymous reads (`LIST`, `GET`) while requiring authentication for writes (`POST`, `PATCH`, `DELETE`). + +## Adding a token endpoint + +You still need to implement the `/token` endpoint that issues JWT tokens. Here's a minimal example: + +!!! danger "Demo only — not production-ready" + + The example below issues a JWT to **any** username/password pair without + verifying credentials. It also uses a hardcoded `SECRET_KEY`. Before + deploying: + + - Replace the stub with real credential verification (database lookup, + password hashing with `passlib[bcrypt]`). + - Load `SECRET_KEY` from an environment variable or secrets manager. + - Validate `exp`, `sub`, algorithm, audience, and issuer on every + protected request via a dedicated dependency — `OAuth2PasswordBearer` + only checks for the presence of a bearer header; any string passes. + +```python +from datetime import datetime, timedelta, timezone +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordRequestForm +from jose import jwt + +SECRET_KEY = "your-secret-key" +ALGORITHM = "HS256" + + +@app.post("/token") +async def login(form_data: OAuth2PasswordRequestForm = Depends()): + # Verify credentials here (e.g., check against a user table) + # For demo purposes, always issue a token: + access_token = jwt.encode( + { + "sub": form_data.username, + "exp": datetime.now(timezone.utc) + timedelta(hours=1), + }, + SECRET_KEY, + algorithm=ALGORITHM, + ) + return {"access_token": access_token, "token_type": "bearer"} +``` + +!!! tip "Dependencies" + + For the token endpoint example above, install `python-jose`: + + ```bash + pip install python-jose[cryptography] + ``` + + If you plan to hash passwords with `passlib`, install it separately: + + ```bash + pip install passlib[bcrypt] + ``` + +## Next steps + +- [Overriding Handlers](overrides.md) — subclass for custom logic +- [Custom Routes](custom-routes.md) — adding non-CRUD endpoints diff --git a/docs/changelog.md b/docs/changelog.md new file mode 100644 index 0000000..b2f6554 --- /dev/null +++ b/docs/changelog.md @@ -0,0 +1,37 @@ +# Changelog + +## v1.3.0 + +- **Declarative eager loading** via `RelatedConfig` inside Pydantic schemas. + Add `select_related = [...]` and/or `prefetch_related = [...]` to a schema's inner `RelatedConfig` class, and `BaseViewset` / `AsyncBaseViewset` automatically applies `joinedload` / `selectinload` (SQLAlchemy) or `prefetch_related` (Tortoise) on `LIST` and `GET` endpoints. This eliminates N+1 queries without duplicating configuration between schemas and viewsets. +- All adapter methods (`get_list_queryset`, `get_element_by_id`, and their async counterparts) accept optional `select_related` and `prefetch_related` arguments for explicit overrides. +- Full backward compatibility: new parameters default to `None`; existing code works unchanged. + +## v1.2.2 + +- **Documentation overhaul**: added async + Pydantic v2 quickstart and override examples to README. + +## v1.2.1 + +- **Hotfix: `SQLAlchemyAdapter` no longer crashes at construction** when the async DB-API driver (`aiosqlite` / `asyncpg` / `aiomysql`) is missing. Sync-only setups keep working out of the box; `get_async_session()` raises a helpful `RuntimeError` lazily if you try to use async without the driver. + +## v1.2.0 + +- Pydantic v2 first: CRUD handlers use `model_dump(exclude_unset=...)`, fixing PATCH semantics that previously overwrote unset fields with defaults. +- Lazy `db_conf`: importing the package no longer creates SQLAlchemy engines unless they are needed, and works without async drivers installed. +- Single source of truth for sync-to-async URL conversion and the default adapter singleton. +- Internal `register()` deduplicated between sync and async viewsets via a shared mixin. +- PEP 621 `pyproject.toml`, `python_requires>=3.9`, FastAPI `>=0.110`, ruff/black/mypy preconfigured. + +## v1.1.0 + +- Multi-ORM support via adapters (SQLAlchemy default, optional Tortoise and Peewee). +- `ORMFactory` and environment-driven `ORM_TYPE` configuration. + +## Links + +- [GitHub releases](https://github.com/svalench/fastapi_viewsets/releases) +- [PyPI](https://pypi.org/project/fastapi-viewsets/) +- [Release notes (v1.3.0)](https://github.com/svalench/fastapi_viewsets/blob/master/RELEASE_1.3.0.md) +- [Release notes (v1.2.0)](https://github.com/svalench/fastapi_viewsets/blob/master/RELEASE_1.2.0.md) +- [Release notes (v1.1.0)](https://github.com/svalench/fastapi_viewsets/blob/master/RELEASE_1.1.0.md) diff --git a/docs/comparison.md b/docs/comparison.md new file mode 100644 index 0000000..c481b45 --- /dev/null +++ b/docs/comparison.md @@ -0,0 +1,66 @@ +# Comparison with Alternatives + +## Why fastapi-viewsets + +`fastapi-viewsets` brings Django REST Framework-style ergonomics to FastAPI: define a model and a schema, call `register()`, and get a full CRUD API with pagination, OAuth2, and OpenAPI docs — all type-safe with Pydantic v2. + +## Feature comparison + +| Approach | Developer experience | ORM support | Permissions | Filtering | +| --- | --- | --- | --- | --- | +| **fastapi-viewsets** | One `BaseViewset` registers CRUD routes | SQLAlchemy sync/async, Tortoise, Peewee via adapters | OAuth2 per logical method via `register` | `limit`/`offset` today; `search` and advanced filters on Roadmap | +| fastapi-crudrouter | CRUD-focused generators, less ViewSet-shaped | SQLAlchemy, Tortoise, Ormar, Gino, databases | Custom middleware/deps | Often extended manually | +| Hand-rolled FastAPI | Full control, most boilerplate | Any ORM you integrate | Fully custom | Fully custom | + +## When to use fastapi-viewsets + +**Good fit if you:** + +- Want DRF-style declarative CRUD without boilerplate +- Use SQLAlchemy (sync or async), Tortoise ORM, or Peewee +- Need Pydantic v2 type safety with OpenAPI auto-generation +- Want declarative eager loading to eliminate N+1 queries +- Need OAuth2 on specific methods (e.g. reads public, writes protected) + +**Consider alternatives if you:** + +- Need complex filtering, ordering, or search out of the box (these are on the [roadmap](#roadmap) but not yet built) +- Use an ORM without an adapter (e.g. SQLModel, Beanie) — though you can write a custom `BaseORMAdapter` +- Need a full permissions framework with role-based access control + +## Declarative eager loading + +A unique feature of `fastapi-viewsets` is the `RelatedConfig` class on Pydantic schemas, which eliminates N+1 queries without touching the viewset: + +```python +class PostSchema(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: int + title: str + author: AuthorSchema + + class RelatedConfig: + select_related = ["author"] # FK → single JOIN + prefetch_related = ["tags"] # M2M → separate SELECT IN +``` + +A key differentiator of `fastapi-viewsets` is the schema-driven approach to eager loading via `RelatedConfig` — you declare eager-loading strategy on the Pydantic schema, not on the viewset or query. + +## ORM adapter architecture + +The adapter pattern means you can switch ORMs by changing one environment variable: + +```dotenv +ORM_TYPE=sqlalchemy # or "tortoise" or "peewee" +``` + +Each adapter implements the same `BaseORMAdapter` interface, so viewset code stays identical regardless of the ORM. You can also create adapters programmatically without environment variables using `ORMFactory.create_adapter()`. + +## Roadmap + +| Item | Target | Status | +| --- | --- | --- | +| Wire `search` on LIST to real database queries | v1.4 | Planned | +| Transaction helpers (`begin` / `atomic`) across adapters | v1.4 | Planned | +| Declarative ordering (`order_by`) on LIST endpoints | v1.4 | Planned | +| Advanced filters (`__gt`, `__lt`, `__in`) via query params | v1.5 | Planned | diff --git a/docs/custom-routes.md b/docs/custom-routes.md new file mode 100644 index 0000000..b6fb729 --- /dev/null +++ b/docs/custom-routes.md @@ -0,0 +1,114 @@ +# Custom Routes & Permissions + +## Adding custom routes + +There is no `get_queryset` hook; scope queries by subclassing `BaseViewset` and overriding `list()`, `get_element()`, or related handlers. The class subclasses `APIRouter`, so you can attach extra endpoints with `add_api_route` **before** calling `register()` if the paths must take priority over `/{id}`: + +```python +from fastapi_viewsets import BaseViewset + + +class ItemsWithStats(BaseViewset): + """Adds a custom read-only route alongside generated CRUD.""" + + def __init__(self, *args, **kwargs): + """Register static paths before CRUD routes.""" + super().__init__(*args, **kwargs) + self.add_api_route( + f"{self.endpoint}/stats", + self.collection_stats, + methods=["GET"], + tags=self.tags or [], + name="items_stats", + ) + + def collection_stats(self) -> dict[str, str]: + """Return a minimal summary for monitoring or health checks.""" + return {"resource": self.endpoint.strip("/")} + + +# Instantiate with model, response_model, and db_session (see quickstart), +# then call register(). +``` + +### Route ordering matters + +Static routes (like `/items/stats`) must be registered **before** `register()` adds the `/{id}` route. Otherwise FastAPI may match `stats` against the `{id}` path parameter. + +The internal `register()` method already sorts `LIST` before `GET` for this reason, but custom routes you add yourself need manual ordering. + +## Permissions model + +`fastapi-viewsets` uses a per-method OAuth2 approach rather than a full permissions framework: + +- **Authentication** — `OAuth2PasswordBearer` on selected methods (see [Authentication](authentication.md)) +- **Authorization** — implement in your override by inspecting the `token` or request user + +Example with role-based access: + +```python +from typing import Optional, Union + +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer + +from fastapi_viewsets import AsyncBaseViewset + +oauth2 = OAuth2PasswordBearer(tokenUrl="/token") + + +def _noop() -> None: + """Placeholder dependency when OAuth2 is not active.""" + return None + + +class AdminOnlyDelete(AsyncBaseViewset): + """Only admin users can DELETE; reads are public.""" + + # NOTE: ``get_current_user`` is your own helper that decodes the JWT + # and returns a user object with an ``is_admin`` flag. + # Replace it with your actual authentication dependency. + + async def delete_element( + self, + id: Union[int, str], + token: Optional[str] = Depends(oauth2), + ) -> dict: + # Validate the token and check role + user = await get_current_user(token) # your dependency/helper + if not user.is_admin: + raise HTTPException( + status.HTTP_403_FORBIDDEN, + "Admin access required", + ) + return await super().delete_element(id, token=token) + + +# Register with DELETE protected: +viewset = AdminOnlyDelete( + endpoint="/items", + model=Item, + response_model=ItemSchema, + db_session=get_async_session, +) +viewset.register( + methods=["LIST", "GET", "DELETE"], + oauth_protect=oauth2, + protected_methods=["DELETE"], +) +``` + +## The `APIRouter` connection + +`BaseViewset` and `AsyncBaseViewset` both subclass `fastapi.APIRouter`. This means: + +- `app.include_router(viewset)` works out of the box +- `add_api_route()`, `add_api_websocket_route()`, and all router methods are available +- Middleware and dependencies from the parent app apply to viewset routes +- You can nest viewsets in sub-routers with `APIRouter`'s `include_router` + +## Next steps + +- [Overriding Handlers](overrides.md) — subclassing patterns +- [Authentication](authentication.md) — OAuth2 setup details +- [API Reference](api-reference.md) — full method documentation diff --git a/docs/eager-loading.md b/docs/eager-loading.md new file mode 100644 index 0000000..1d50d0d --- /dev/null +++ b/docs/eager-loading.md @@ -0,0 +1,77 @@ +# Eager Loading (`select_related` / `prefetch_related`) + +If your Pydantic schema includes nested models (e.g. `author: UserSchema`), SQLAlchemy will normally emit extra queries for every row — the classic **N+1 problem**. You can fix this declaratively by adding an inner `RelatedConfig` class to the schema. + +## How it works + +```python +from pydantic import BaseModel, ConfigDict + + +class AuthorSchema(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: int + name: str + + +class PostSchema(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: int + title: str + author: AuthorSchema # nested model → signals the need for a join + + class RelatedConfig: + select_related = ["author"] # FK / many-to-one → one JOIN query + prefetch_related = ["tags"] # collections / M2M → separate SELECT IN +``` + +When `PostSchema` is passed as `response_model` to a viewset, `LIST` and `GET` automatically apply the correct eager-loading strategy: + +```python +posts = AsyncBaseViewset( + endpoint="/posts", + model=Post, + response_model=PostSchema, + db_session=get_async_session, +) +``` + +No additional configuration on the viewset itself — the schema drives the behavior. + +## ORM-specific behavior + +| ORM | `select_related` | `prefetch_related` | +| --- | --- | --- | +| SQLAlchemy | `joinedload` (single query, FK side) | `selectinload` (two queries, collection side, no Cartesian product) | +| Tortoise ORM | Native `prefetch_related()` for both | Native `prefetch_related()` | +| Peewee | `.join()` for `select_related` | Not supported | + +## When to use which + +- **`select_related`** — for ForeignKey / many-to-one relationships. Uses a SQL `JOIN`, so everything comes back in one query. Best for single-object relations (e.g. `Post.author`). + +- **`prefetch_related`** — for collections / many-to-many. Runs a separate `SELECT ... WHERE id IN (...)` query to avoid the Cartesian product. Best for list relations (e.g. `Post.tags`, `Post.comments`). + +## Explicit overrides + +You can also override the config per-call when using the low-level utilities directly: + +```python +from fastapi_viewsets.async_utils import get_list_queryset + +posts = await get_list_queryset( + Post, + db_session=get_async_session, + response_model=PostSchema, + select_related=["author"], + prefetch_related=["tags", "comments"], +) +``` + +The adapter methods `get_list_queryset`, `get_element_by_id`, and their async counterparts all accept optional `select_related` and `prefetch_related` arguments for explicit overrides. + +## Backward compatibility + +!!! info "All new parameters default to `None`" + + Existing code and tests continue to work unchanged. If your schema doesn't have a `RelatedConfig` class, no eager loading is applied — identical to previous behavior. diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..059c509 --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,90 @@ +# Installation + +## Requirements + +| Dependency | Version | +| --- | --- | +| Python | >= 3.9 | +| FastAPI | >= 0.110 | +| Pydantic | >= 2.5, < 3 | +| SQLAlchemy | >= 1.4.36 | +| python-dotenv | >= 0.19 | + +## Install from PyPI + +```bash +pip install fastapi-viewsets +``` + +## Optional extras + +SQLAlchemy is a core dependency (installed automatically). The extras below +add ORM-specific packages when you need Tortoise or Peewee: + +=== "SQLAlchemy" + + ```bash + pip install "fastapi-viewsets[sqlalchemy]" + ``` + + For async SQLAlchemy, also install a driver: + + ```bash + pip install aiosqlite # SQLite (development) + pip install asyncpg # PostgreSQL + pip install aiomysql # MySQL + ``` + +=== "Tortoise ORM" + + ```bash + pip install "fastapi-viewsets[tortoise]" # pulls in asyncpg + ``` + + For MySQL with Tortoise: + + ```bash + pip install "fastapi-viewsets[tortoise]" aiomysql + ``` + +=== "Peewee" + + ```bash + pip install "fastapi-viewsets[peewee]" + + # PostgreSQL driver + pip install psycopg2-binary + + # MySQL driver + pip install pymysql + ``` + +=== "Test dependencies" + + ```bash + pip install "fastapi-viewsets[test]" + ``` + + Includes `pytest`, `pytest-asyncio`, `pytest-cov`, `httpx`, `faker`, and `aiosqlite`. + +## Install from source (development) + +```bash +git clone https://github.com/svalench/fastapi_viewsets.git +cd fastapi_viewsets +pip install -e ".[test,lint]" +``` + +## Verifying the installation + +```bash +python -c "from fastapi_viewsets import BaseViewset, AsyncBaseViewset; print('OK')" +``` + +If this prints `OK`, you're ready to go. + +## What's next + +- [Sync Quickstart](quickstart-sync.md) — a complete CRUD app in ~25 lines +- [Async Quickstart](quickstart-async.md) — `AsyncBaseViewset` with async SQLAlchemy +- [ORM Adapters](orm-adapters.md) — configuring Tortoise or Peewee diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..86b123a --- /dev/null +++ b/docs/index.md @@ -0,0 +1,130 @@ +--- +hide: + - navigation + - toc +--- + +# fastapi-viewsets + +**Django REST Framework-style ViewSets for FastAPI** — auto-generate CRUD endpoints from SQLAlchemy, Tortoise ORM, or Peewee models in minutes. + +[![PyPI version](https://badge.fury.io/py/fastapi-viewsets.svg)](https://pypi.org/project/fastapi-viewsets/) +[![Python versions](https://img.shields.io/pypi/pyversions/fastapi-viewsets.svg)](https://pypi.org/project/fastapi-viewsets/) +[![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](https://github.com/svalench/fastapi_viewsets/blob/master/LICENSE) +[![CI](https://github.com/svalench/fastapi_viewsets/actions/workflows/test.yml/badge.svg?branch=master)](https://github.com/svalench/fastapi_viewsets/actions/workflows/test.yml) +[![codecov](https://codecov.io/gh/svalench/fastapi_viewsets/graph/badge.svg)](https://codecov.io/gh/svalench/fastapi_viewsets) +[![Downloads/month](https://static.pepy.tech/badge/fastapi-viewsets/month)](https://pepy.tech/project/fastapi-viewsets) + +--- + +## Why fastapi-viewsets + +
+ +- :material-lightning-bolt: **Less boilerplate** + + --- + + Register `LIST`, `GET`, `POST`, `PUT`, `PATCH`, and `DELETE` from one class — no repetitive route definitions. + +- :material-database-cog: **ORM-agnostic core** + + --- + + Pluggable adapters for SQLAlchemy (sync & async), Tortoise ORM, and Peewee via `ORM_TYPE` / optional extras. + +- :material-shield-check: **Typed & Pydantic-first** + + --- + + OpenAPI tags and response schemas generated from your `response_model`. Full Pydantic v2 support. + +- :material-link-variant: **Declarative eager loading** + + --- + + `select_related` / `prefetch_related` via an inner `RelatedConfig` class on Pydantic schemas — eliminates N+1 without touching the viewset. + +- :material-page-layout-sidebar-left: **Built-in pagination** + + --- + + `limit` / `offset` on LIST endpoints out of the box. + +- :material-lock: **OAuth2 on selected operations** + + --- + + Protect specific CRUD methods with `OAuth2PasswordBearer` via `register()`. + +
+ +--- + +## 30-second example + +```python +from fastapi import FastAPI +from pydantic import BaseModel, ConfigDict +from sqlalchemy import Column, Integer, String +from typing import Optional + +from fastapi_viewsets import BaseViewset +from fastapi_viewsets.db_conf import Base, engine, get_session + +app = FastAPI() + + +class Item(Base): + __tablename__ = "items" + id = Column(Integer, primary_key=True) + name = Column(String(255), nullable=False) + + +class ItemSchema(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: Optional[int] = None + name: str + + +Base.metadata.create_all(bind=engine) +items = BaseViewset( + endpoint="/items", + model=Item, + response_model=ItemSchema, + db_session=get_session, + tags=["items"], +) +items.register(methods=["LIST", "GET", "POST", "PATCH", "DELETE"]) +app.include_router(items) +``` + +Run it: + +```bash +uvicorn main:app --reload +``` + +That's it — you now have `GET /items`, `GET /items/{id}`, `POST /items`, `PATCH /items/{id}`, and `DELETE /items/{id}` with full OpenAPI docs at `/docs`. + +--- + +## Feature matrix + +| Feature | SQLAlchemy (sync) | SQLAlchemy (async) | Tortoise ORM | Peewee | +| --- | --- | --- | --- | --- | +| `BaseViewset` / `AsyncBaseViewset` CRUD | ✅ | ✅ (`AsyncBaseViewset`) | ✅ via adapter + async session | ✅ via adapter | +| `limit` / `offset` on LIST | ✅ | ✅ | ✅ | ✅ | +| OAuth2 on selected methods | ✅ | ✅ | ✅ | ✅ | +| Declarative eager loading | ✅ | ✅ | ✅ (`prefetch_related`) | ✅ (`select_related`) | +| `search` query on LIST | Roadmap | Roadmap | Roadmap | Roadmap | +| Declarative ordering / advanced filters | Roadmap | Roadmap | Roadmap | Roadmap | + +--- + +## Next steps + +- [Getting Started](getting-started.md) — install and run your first viewset +- [Async Quickstart](quickstart-async.md) — `AsyncBaseViewset` with SQLAlchemy 2.x +- [ORM Adapters](orm-adapters.md) — Tortoise and Peewee configuration +- [Eager Loading](eager-loading.md) — eliminate N+1 queries with `RelatedConfig` diff --git a/docs/orm-adapters.md b/docs/orm-adapters.md new file mode 100644 index 0000000..b05e651 --- /dev/null +++ b/docs/orm-adapters.md @@ -0,0 +1,262 @@ +# ORM Adapters + +`fastapi-viewsets` doesn't bundle database drivers — you pick them per stack. The library reads configuration from environment variables (loaded via `python-dotenv` from `.env`). Pick the ORM with `ORM_TYPE`, then set the URL with `_DATABASE_URL` (or the generic `DATABASE_URL`). + +## Supported ORMs + +| ORM | Sync | Async | Extra | +| --- | --- | --- | --- | +| SQLAlchemy | ✅ `BaseViewset` | ✅ `AsyncBaseViewset` | `[sqlalchemy]` | +| Tortoise ORM | — | ✅ `AsyncBaseViewset` | `[tortoise]` | +| Peewee | ✅ `BaseViewset` | — | `[peewee]` | + +## Database driver matrix + +| ORM | PostgreSQL | MySQL | MSSQL | +| --- | --- | --- | --- | +| **SQLAlchemy (sync)** | `psycopg[binary]` or `psycopg2-binary` | `pymysql` or `mysqlclient` | `pyodbc` + ODBC Driver 17/18 | +| **SQLAlchemy (async)** | `asyncpg` | `aiomysql` or `asyncmy` | `aioodbc` + ODBC Driver 17/18 | +| **Tortoise ORM** | `asyncpg` (included in `[tortoise]` extra) | `aiomysql` (install separately) | Not supported by Tortoise | +| **Peewee** | `psycopg2-binary` | `pymysql` or `mysqlclient` | Not supported by this adapter | + +!!! note "Auto sync-to-async URL conversion" + + The `SQLAlchemyAdapter` auto-converts a sync URL to its async counterpart: + + - `postgresql://` → `postgresql+asyncpg://` + - `mysql://` → `mysql+aiomysql://` + - `sqlite:///` → `sqlite+aiosqlite:///` + + For MSSQL you must set the async URL explicitly via `SQLALCHEMY_ASYNC_DATABASE_URL`. If the matching async driver is not installed, `SQLAlchemyAdapter` falls back to sync-only mode and `get_async_session()` raises a helpful `RuntimeError` (since v1.2.1). + +--- + +## SQLAlchemy (sync and async) + +### PostgreSQL + +```bash +pip install "fastapi-viewsets[sqlalchemy]" "psycopg[binary]" asyncpg +``` + +### MySQL + +```bash +pip install "fastapi-viewsets[sqlalchemy]" pymysql aiomysql +``` + +### MSSQL + +Requires Microsoft ODBC Driver 17 or 18 on the host: + +```bash +pip install "fastapi-viewsets[sqlalchemy]" pyodbc aioodbc +``` + +### `.env` examples + +=== "PostgreSQL" + + ```dotenv + ORM_TYPE=sqlalchemy + SQLALCHEMY_DATABASE_URL=postgresql+psycopg://user:pass@db.example.com:5432/app + # Optional explicit async URL; otherwise auto-derived to postgresql+asyncpg:// + SQLALCHEMY_ASYNC_DATABASE_URL=postgresql+asyncpg://user:pass@db.example.com:5432/app + ``` + +=== "MySQL" + + ```dotenv + ORM_TYPE=sqlalchemy + SQLALCHEMY_DATABASE_URL=mysql+pymysql://user:pass@db.example.com:3306/app?charset=utf8mb4 + SQLALCHEMY_ASYNC_DATABASE_URL=mysql+aiomysql://user:pass@db.example.com:3306/app?charset=utf8mb4 + ``` + +=== "MSSQL" + + ```dotenv + ORM_TYPE=sqlalchemy + # URL-encode the ODBC driver name ("+" instead of spaces). + SQLALCHEMY_DATABASE_URL=mssql+pyodbc://user:pass@db.example.com:1433/app?driver=ODBC+Driver+18+for+SQL+Server&Encrypt=yes&TrustServerCertificate=no + SQLALCHEMY_ASYNC_DATABASE_URL=mssql+aioodbc://user:pass@db.example.com:1433/app?driver=ODBC+Driver+18+for+SQL+Server&Encrypt=yes&TrustServerCertificate=no + ``` + +Use `BaseViewset` for sync code or `AsyncBaseViewset` for async code — see [Sync Quickstart](quickstart-sync.md) and [Async Quickstart](quickstart-async.md). + +--- + +## Tortoise ORM + +Tortoise is async-only. The adapter takes a database URL plus a list of model modules to register on startup. + +```bash +# PostgreSQL +pip install "fastapi-viewsets[tortoise]" # pulls in asyncpg + +# MySQL +pip install "fastapi-viewsets[tortoise]" aiomysql +``` + +### `.env` examples + +=== "PostgreSQL" + + ```dotenv + ORM_TYPE=tortoise + TORTOISE_DATABASE_URL=postgres://user:pass@db.example.com:5432/app + TORTOISE_MODELS=["app.models"] + TORTOISE_APP_LABEL=models + ``` + +=== "MySQL" + + ```dotenv + ORM_TYPE=tortoise + TORTOISE_DATABASE_URL=mysql://user:pass@db.example.com:3306/app + TORTOISE_MODELS=["app.models"] + TORTOISE_APP_LABEL=models + ``` + +### Usage + +```python +from contextlib import asynccontextmanager + +from fastapi import FastAPI + +from fastapi_viewsets import AsyncBaseViewset +from fastapi_viewsets.orm.factory import ORMFactory + +adapter = ORMFactory.get_default_adapter() # built from the env vars above + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """Open the Tortoise connection pool at startup. + + The adapter also initialises Tortoise lazily on the first DB call, + but calling ``initialize()`` here gives you control over schema creation + and avoids a cold-start penalty on the first request. + """ + await adapter.initialize(generate_schemas=True) + yield + await adapter.close() + + +app = FastAPI(lifespan=lifespan) + + +# Define your Tortoise models in app/models.py and pass them to AsyncBaseViewset. +# from app.models import Item +# from app.schemas import ItemSchema +# items = AsyncBaseViewset( +# endpoint="/items", +# model=Item, +# response_model=ItemSchema, +# db_session=adapter.get_async_session, +# orm_adapter=adapter, +# tags=["items"], +# ) +# items.register(methods=["LIST", "GET", "POST", "PATCH", "DELETE"]) +# app.include_router(items) +``` + +!!! note "Production schema management" + + `generate_schemas=True` is convenient for development. In production, use + [Aerich](https://github.com/tortoise/aerich) or another migration tool + instead of auto-generating schemas at startup. + +!!! warning "MSSQL not supported" + + MSSQL is not supported by Tortoise ORM. Use SQLAlchemy with `aioodbc` for SQL Server. + +--- + +## Peewee + +Peewee is sync-only. The adapter parses the URL and instantiates the right `Database` class. + +```bash +# PostgreSQL +pip install "fastapi-viewsets[peewee]" psycopg2-binary + +# MySQL +pip install "fastapi-viewsets[peewee]" pymysql +``` + +### `.env` examples + +=== "PostgreSQL" + + ```dotenv + ORM_TYPE=peewee + PEEWEE_DATABASE_URL=postgresql://user:pass@db.example.com:5432/app + ``` + +=== "MySQL" + + ```dotenv + ORM_TYPE=peewee + PEEWEE_DATABASE_URL=mysql://user:pass@db.example.com:3306/app + ``` + +### Usage + +```python +from fastapi import FastAPI + +from fastapi_viewsets import BaseViewset +from fastapi_viewsets.orm.factory import ORMFactory + +app = FastAPI() +adapter = ORMFactory.get_default_adapter() # built from the env vars above + +# from app.models import Item # peewee.Model subclass +# from app.schemas import ItemSchema # Pydantic v2 schema +# items = BaseViewset( +# endpoint="/items", +# model=Item, +# response_model=ItemSchema, +# db_session=adapter.get_session, +# orm_adapter=adapter, +# tags=["items"], +# ) +# items.register(methods=["LIST", "GET", "POST", "PATCH", "DELETE"]) +# app.include_router(items) +``` + +!!! warning "MSSQL not supported" + + The Peewee adapter only handles `sqlite:///`, `postgresql://`, `postgres://`, and `mysql://`. For SQL Server, use SQLAlchemy. + +--- + +## Building the adapter from code (no env vars) + +When you don't want to rely on environment variables, instantiate the adapter directly and pass it to the viewset via `orm_adapter=`: + +```python +from fastapi_viewsets.orm.factory import ORMFactory + +adapter = ORMFactory.create_adapter( + "sqlalchemy", + { + "database_url": "postgresql+psycopg://user:pass@db.example.com:5432/app", + "async_database_url": "postgresql+asyncpg://user:pass@db.example.com:5432/app", + }, +) +``` + +This is useful for testing, multi-tenant setups, or when configuration comes from a secrets manager rather than `.env`. + +## The `ORMFactory` class + +The `ORMFactory` is the central entry point for adapter resolution: + +| Method | Returns | Description | +| --- | --- | --- | +| `ORMFactory.get_default_adapter()` | `BaseORMAdapter` | Returns the cached singleton adapter for the current `ORM_TYPE` | +| `ORMFactory.create_adapter(orm_type, config)` | `BaseORMAdapter` | Creates a new adapter instance from a dict config | + +All adapters implement the `BaseORMAdapter` abstract interface — see [API Reference](api-reference.md) for the full method list. diff --git a/docs/overrides.md b/docs/overrides.md new file mode 100644 index 0000000..f337868 --- /dev/null +++ b/docs/overrides.md @@ -0,0 +1,161 @@ +# Overriding Handlers (Custom LIST and POST) + +Every CRUD handler is a regular method, so subclassing the viewset is the canonical way to add filtering, ordering, validation, conflict handling, and more. + +## Available handlers + +| Handler | Logical method | HTTP | Path | Default behavior | +| --- | --- | --- | --- | --- | +| `list()` | `LIST` | `GET` | `/` | Paginated list with `limit` / `offset` | +| `get_element()` | `GET` | `GET` | `/{id}` | Retrieve by ID | +| `create_element()` | `POST` | `POST` | `/` | Create from request body | +| `update_element()` | `PUT` / `PATCH` | `PUT` / `PATCH` | `/{id}` | Full or partial update | +| `delete_element()` | `DELETE` | `DELETE` | `/{id}` | Delete by ID | + +## Complete example: search + ordering + conflict handling + +This example subclasses `AsyncBaseViewset` and overrides both `list` (case-insensitive search + simple ordering) and `create_element` (input normalization + mapping `IntegrityError` to 409). + +```python +from typing import List, Optional + +from fastapi import Body, HTTPException, status +from fastapi import Depends +from pydantic import BaseModel, ConfigDict, Field +from sqlalchemy import Column, DateTime, Integer, String, func, select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from fastapi_viewsets import AsyncBaseViewset +from fastapi_viewsets.db_conf import Base, get_async_session + + +class Item(Base): + """Item model with timestamps and a unique name.""" + + __tablename__ = "items_custom" + id = Column(Integer, primary_key=True) + name = Column(String(255), nullable=False, unique=True, index=True) + description = Column(String(1024), nullable=True) + created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) + + +class ItemSchema(BaseModel): + """Single Pydantic v2 schema reused as request and response model. + + Server-controlled fields (``id``, ``created_at``) are optional so + the same schema can be used for POST/PATCH bodies and responses + — ``register()`` patches the body annotation to ``response_model``. + """ + + model_config = ConfigDict(from_attributes=True, str_strip_whitespace=True) + id: Optional[int] = None + name: str = Field(..., min_length=1, max_length=255) + description: Optional[str] = Field(default=None, max_length=1024) + created_at: Optional[object] = None # datetime in real code + + +class ItemsViewSet(AsyncBaseViewset): + """Custom async viewset that overrides LIST and POST.""" + + async def list( # type: ignore[override] + self, + limit: int = 20, + offset: int = 0, + search: Optional[str] = None, + order_by: str = "-created_at", + token: Optional[str] = Depends(lambda: None), + ) -> List[ItemSchema]: + """Custom LIST: case-insensitive search + whitelist ordering. + + Query: ``GET /items?search=foo&order_by=-name&limit=10``. + """ + session: AsyncSession = self.db_session() + try: + stmt = select(self.model) + if search: + stmt = stmt.where(self.model.name.ilike(f"%{search}%")) + + # "-name" → desc, "name" → asc; whitelist allowed columns. + field, desc = (order_by[1:], True) if order_by.startswith("-") else (order_by, False) + column = {"name": self.model.name, "created_at": self.model.created_at}.get(field) + if column is None: + raise HTTPException(status.HTTP_400_BAD_REQUEST, "Unsupported order_by") + stmt = stmt.order_by(column.desc() if desc else column.asc()) + stmt = stmt.offset(offset).limit(limit) + + rows = (await session.execute(stmt)).scalars().all() + return [ItemSchema.model_validate(row) for row in rows] + finally: + await session.close() + + async def create_element( # type: ignore[override] + self, + item: ItemSchema = Body(...), + token: Optional[str] = Depends(lambda: None), + ) -> ItemSchema: + """Custom POST: normalize, persist, map IntegrityError to 409.""" + # Pydantic v2 dump; ``str_strip_whitespace`` already trimmed strings. + payload = item.model_dump(exclude_unset=True, exclude={"id", "created_at"}) + + session: AsyncSession = self.db_session() + try: + obj = self.model(**payload) + session.add(obj) + try: + await session.commit() + except IntegrityError as exc: + await session.rollback() + raise HTTPException( + status.HTTP_409_CONFLICT, + f"Item '{payload.get('name')}' already exists", + ) from exc + await session.refresh(obj) + return ItemSchema.model_validate(obj) + finally: + await session.close() + + +items = ItemsViewSet( + endpoint="/items", + model=Item, + response_model=ItemSchema, + db_session=get_async_session, + tags=["items"], +) +items.register(methods=["LIST", "GET", "POST", "PATCH", "DELETE"]) +``` + +## Key points when overriding + +### Keep method names and the `item` body parameter + +`register()` introspects `list`, `get_element`, `create_element`, `update_element`, `delete_element`. It also rewrites the `item.__annotation__` to `response_model` so the OpenAPI body schema stays consistent — use the same schema for request and response, or pre-validate inside the handler. + +### Adding new query parameters is fine + +`search`, `order_by`, filters, etc. — FastAPI picks them up automatically from the function signature. + +### Manage your own session lifecycle + +In overrides, manage sessions with `try/finally` + `await session.close()`, or use a FastAPI dependency with `yield`. + +### Sync viewsets + +For sync apps, the same pattern applies to `BaseViewset` — just drop the `async`/`await` and use `Session` instead of `AsyncSession`. + +## What `register()` does internally + +When you call `register()`: + +1. **Sorts methods** — `LIST` is registered before `GET` so FastAPI doesn't match the collection path against `/{id}`. +2. **Patches annotations** — if the handler has an `item` parameter and `response_model` is set, the annotation is rewritten to your schema for OpenAPI consistency. +3. **Handles PUT vs PATCH** — both map to `update_element`, but `PATCH` passes `partial=True` (using `model_dump(exclude_unset=True)`). +4. **Applies OAuth2** — if `protected_methods` includes a method and `oauth_protect` is provided, the token dependency is wired via `functools.partial`. +5. **Adds routes** — `add_api_route()` is called with the computed endpoint path, response model, tags, and HTTP method. + +## Next steps + +- [Authentication](authentication.md) — OAuth2 on selected operations +- [Custom Routes](custom-routes.md) — adding endpoints alongside CRUD +- [API Reference](api-reference.md) — full class and method documentation diff --git a/docs/pagination-filtering.md b/docs/pagination-filtering.md new file mode 100644 index 0000000..55bbc6b --- /dev/null +++ b/docs/pagination-filtering.md @@ -0,0 +1,106 @@ +# Pagination, Filtering & Ordering + +## Pagination + +`BaseViewset.list` (and `AsyncBaseViewset.list`) map `limit` and `offset` to query parameters on the LIST route. + +``` +GET /items?limit=10&offset=20 +``` + +| Parameter | Type | Default | Description | +| --- | --- | --- | --- | +| `limit` | `Optional[int]` | `10` | Maximum number of items to return | +| `offset` | `Optional[int]` | `0` | Number of items to skip | +| `search` | `Optional[str]` | `None` | Search query (reserved — see below) | + +Example: + +```text +# Default: GET /items → 10 items, offset 0 +GET /items + +# Custom page: GET /items?limit=50&offset=100 → items 101-150 +GET /items?limit=50&offset=100 +``` + +No additional configuration needed — pagination is built into the default `list()` handler. + +## Filtering + +!!! warning "`search` is reserved but not yet wired" + + The `search` parameter is accepted by `list()` but ORM adapters currently ignore it. Server-side search is on the [Roadmap](#roadmap) for v1.4. + +Until then, subclass `BaseViewset` or `AsyncBaseViewset` and override `list()` with your own filtering logic: + +```python +from typing import List, Optional + +from fastapi import Depends +from sqlalchemy import select + +from fastapi_viewsets import AsyncBaseViewset + + +class ItemsWithSearch(AsyncBaseViewset): + async def list( + self, + limit: int = 20, + offset: int = 0, + search: Optional[str] = None, + token: Optional[str] = Depends(lambda: None), + ) -> list: + """Custom LIST with case-insensitive search.""" + session = self.db_session() + try: + stmt = select(self.model) + if search: + stmt = stmt.where(self.model.name.ilike(f"%{search}%")) + stmt = stmt.offset(offset).limit(limit) + rows = (await session.execute(stmt)).scalars().all() + return [self.response_model.model_validate(row) for row in rows] + finally: + await session.close() +``` + +See [Overriding Handlers](overrides.md) for the full example with search + ordering + conflict handling. + +## Ordering + +There is no built-in `order_by` helper yet. Override `list()` with an ordered query: + +```python +from sqlalchemy import select + + +class OrderedItems(AsyncBaseViewset): + async def list( + self, + limit: int = 10, + offset: int = 0, + token: Optional[str] = Depends(lambda: None), + ): + session = self.db_session() + try: + stmt = select(self.model).order_by(self.model.created_at.desc()) + stmt = stmt.offset(offset).limit(limit) + rows = (await session.execute(stmt)).scalars().all() + return [self.response_model.model_validate(row) for row in rows] + finally: + await session.close() +``` + +## Roadmap + +| Item | Target | Status | +| --- | --- | --- | +| Wire `search` on LIST to real database queries | v1.4 | Planned | +| Declarative ordering (`order_by`) on LIST endpoints | v1.4 | Planned | +| Advanced filters (`__gt`, `__lt`, `__in`) via query params | v1.5 | Planned | +| Transaction helpers (`begin` / `atomic`) across adapters | v1.4 | Planned | + +## Next steps + +- [Overriding Handlers](overrides.md) — complete override example +- [API Reference](api-reference.md) — `list()` signature and parameters diff --git a/docs/quickstart-async.md b/docs/quickstart-async.md new file mode 100644 index 0000000..822bcbb --- /dev/null +++ b/docs/quickstart-async.md @@ -0,0 +1,112 @@ +# Async Quickstart (SQLAlchemy 2.x + Pydantic v2) + +`AsyncBaseViewset` mirrors `BaseViewset` but every CRUD handler is `async`, backed by an async SQLAlchemy `AsyncSession`. + +## Prerequisites + +```bash +pip install "fastapi-viewsets[sqlalchemy]" aiosqlite +``` + +!!! tip "Drivers" + + For production, use `asyncpg` (PostgreSQL) or `aiomysql` (MySQL) instead of `aiosqlite`. + +## Configuration + +Point `SQLALCHEMY_DATABASE_URL` (or `SQLALCHEMY_ASYNC_DATABASE_URL`) at an async-capable URL. The package auto-converts: + +| Sync URL | Async URL | +| --- | --- | +| `sqlite:///` | `sqlite+aiosqlite:///` | +| `postgresql://` | `postgresql+asyncpg://` | +| `mysql://` | `mysql+aiomysql://` | + +Create a `.env`: + +```dotenv +ORM_TYPE=sqlalchemy +SQLALCHEMY_DATABASE_URL=sqlite:///./test.db +# Async URL auto-derived; or set explicitly: +# SQLALCHEMY_ASYNC_DATABASE_URL=sqlite+aiosqlite:///./test.db +``` + +## Full example + +```python +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from pydantic import BaseModel, ConfigDict +from sqlalchemy import Column, Integer, String +from typing import Optional + +from fastapi_viewsets import AsyncBaseViewset +from fastapi_viewsets.db_conf import ( + Base, + async_engine, + get_async_session, +) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + # Create tables once on startup using the async engine. + async with async_engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + yield + + +app = FastAPI(lifespan=lifespan) + + +class Item(Base): + """Async-friendly SQLAlchemy model.""" + + __tablename__ = "items_async" + id = Column(Integer, primary_key=True) + name = Column(String(255), nullable=False) + + +class ItemSchema(BaseModel): + """Pydantic v2 schema reused as request and response model.""" + + model_config = ConfigDict(from_attributes=True) + id: Optional[int] = None + name: str + + + +items = AsyncBaseViewset( + endpoint="/items", + model=Item, + response_model=ItemSchema, + db_session=get_async_session, + tags=["items"], +) +items.register(methods=["LIST", "GET", "POST", "PATCH", "DELETE"]) +app.include_router(items) +``` + +## Key differences from sync + +| Aspect | `BaseViewset` (sync) | `AsyncBaseViewset` (async) | +| --- | --- | --- | +| Handler signature | `def list(...)` | `async def list(...)` | +| Session type | `Session` | `AsyncSession` | +| Session factory | `get_session` | `get_async_session` | +| Table creation | `Base.metadata.create_all(bind=engine)` | `await conn.run_sync(Base.metadata.create_all)` | +| PATCH semantics | `model_dump(exclude_unset=True)` | Same — unset fields preserved | + +## Notes + +- **Pydantic v2 is required** (`pydantic>=2.5`). Use `model_config = ConfigDict(from_attributes=True)` instead of the v1 `class Config: orm_mode = True`. +- **PATCH** uses `model_dump(exclude_unset=True)` internally, so unset fields are not overwritten with defaults. +- **Missing async driver**: if `aiosqlite` / `asyncpg` / `aiomysql` is not installed, sync usage still works — only `get_async_session()` raises a helpful `RuntimeError` (since v1.2.1). +- **MSSQL**: for SQL Server, set the async URL explicitly via `SQLALCHEMY_ASYNC_DATABASE_URL` using `mssql+aioodbc://...`. + +## Next steps + +- [Eager Loading](eager-loading.md) — eliminate N+1 queries with `RelatedConfig` +- [Overriding Handlers](overrides.md) — custom search, ordering, and conflict handling +- [ORM Adapters](orm-adapters.md) — Tortoise and Peewee setup diff --git a/docs/quickstart-sync.md b/docs/quickstart-sync.md new file mode 100644 index 0000000..2adc431 --- /dev/null +++ b/docs/quickstart-sync.md @@ -0,0 +1,152 @@ +# Sync Quickstart (SQLAlchemy) + +This guide walks through a complete CRUD API using `BaseViewset` with synchronous SQLAlchemy. + +!!! info "Prerequisites" + + ```bash + pip install "fastapi-viewsets[sqlalchemy]" + ``` + +## Full example + +Save as `main.py` in an empty folder and run `python main.py` or `uvicorn main:app --reload`: + +```python +from fastapi import FastAPI +from pydantic import BaseModel, ConfigDict +from sqlalchemy import Column, Integer, String +from typing import Optional + +from fastapi_viewsets import BaseViewset +from fastapi_viewsets.db_conf import Base, engine, get_session + +app = FastAPI() + + +class Item(Base): + """Example SQLAlchemy model.""" + + __tablename__ = "items" + id = Column(Integer, primary_key=True) + name = Column(String(255), nullable=False) + + +class ItemSchema(BaseModel): + """Pydantic model for request and response bodies.""" + + model_config = ConfigDict(from_attributes=True) + id: Optional[int] = None + name: str + + +Base.metadata.create_all(bind=engine) +items = BaseViewset( + endpoint="/items", + model=Item, + response_model=ItemSchema, + db_session=get_session, + tags=["items"], +) +items.register(methods=["LIST", "GET", "POST", "PATCH", "DELETE"]) +app.include_router(items) + +if __name__ == "__main__": + import uvicorn + + uvicorn.run(app, host="127.0.0.1", port=8000) +``` + +## What just happened + +1. **Model** — `Item` is a standard SQLAlchemy declarative model. It inherits from `Base`, which is the lazy SQLAlchemy declarative base provided by `fastapi_viewsets.db_conf`. + +2. **Schema** — `ItemSchema` is a Pydantic v2 model with `model_config = ConfigDict(from_attributes=True)`. This enables `model_validate()` to read directly from ORM objects (replacing the old v1 `class Config: orm_mode = True`). + +3. **Viewset** — `BaseViewset` is instantiated with: + - `endpoint` — the base URL path (`"/items"`) + - `model` — the SQLAlchemy model class + - `response_model` — the Pydantic schema (used for both request bodies and responses) + - `db_session` — a session factory (here, `get_session` from `db_conf`) + - `tags` — OpenAPI tag grouping + +4. **Register** — `register()` wires the CRUD routes. Pass a list of logical methods: + + | Method | HTTP | Path | Description | + | --- | --- | --- | --- | + | `LIST` | `GET` | `/items` | List with `limit` / `offset` | + | `GET` | `GET` | `/items/{id}` | Retrieve single item | + | `POST` | `POST` | `/items` | Create item | + | `PUT` | `PUT` | `/items/{id}` | Full update | + | `PATCH` | `PATCH` | `/items/{id}` | Partial update (only set fields) | + | `DELETE` | `DELETE` | `/items/{id}` | Delete item | + +5. **Router** — `app.include_router(items)` mounts the viewset (which subclasses `APIRouter`) on the FastAPI app. + +## Testing the endpoints + +Start the server: + +```bash +uvicorn main:app --reload +``` + +Create an item: + +```bash +curl -X POST http://127.0.0.1:8000/items \ + -H "Content-Type: application/json" \ + -d '{"name": "apple"}' +# {"id":1,"name":"apple"} +``` + +List items: + +```bash +curl http://127.0.0.1:8000/items +# [{"id":1,"name":"apple"}] + +curl "http://127.0.0.1:8000/items?limit=10&offset=0" +``` + +Get a single item: + +```bash +curl http://127.0.0.1:8000/items/1 +# {"id":1,"name":"apple"} +``` + +Patch an item: + +```bash +curl -X PATCH http://127.0.0.1:8000/items/1 \ + -H "Content-Type: application/json" \ + -d '{"name": "banana"}' +# {"id":1,"name":"banana"} +``` + +Delete an item: + +```bash +curl -X DELETE http://127.0.0.1:8000/items/1 +# {"status":true,"text":"successfully deleted"} +``` + +Interactive API docs are available at `http://127.0.0.1:8000/docs`. + +## Environment configuration + +By default, `db_conf` falls back to `sqlite:////base.db`. To use a real database, create a `.env` file: + +```dotenv +ORM_TYPE=sqlalchemy +SQLALCHEMY_DATABASE_URL=postgresql+psycopg://user:pass@localhost:5432/mydb +``` + +See [ORM Adapters](orm-adapters.md) for all supported databases and drivers. + +## Next steps + +- [Async Quickstart](quickstart-async.md) — async version with `AsyncBaseViewset` +- [Overriding Handlers](overrides.md) — custom search, ordering, and error handling +- [Authentication](authentication.md) — protecting endpoints with OAuth2 diff --git a/fastapi_viewsets/orm/tortoise_adapter.py b/fastapi_viewsets/orm/tortoise_adapter.py index f0ffe9b..1edb783 100644 --- a/fastapi_viewsets/orm/tortoise_adapter.py +++ b/fastapi_viewsets/orm/tortoise_adapter.py @@ -58,6 +58,28 @@ async def _ensure_initialized(self): } await Tortoise.init(config=db_config) self._initialized = True + + async def initialize(self, generate_schemas: bool = False) -> None: + """Initialize the Tortoise connection pool. + + Call this in your application lifespan to explicitly open the + connection at startup instead of relying on lazy initialisation. + + Args: + generate_schemas: If True, call ``Tortoise.generate_schemas(safe=True)`` + after init. Useful for development; use Aerich in production. + """ + await self._ensure_initialized() + if generate_schemas: + await Tortoise.generate_schemas(safe=True) + + async def close(self) -> None: + """Close all Tortoise connections. + + Call this in your application lifespan shutdown handler. + """ + await Tortoise.close_connections() + self._initialized = False def get_session(self): """Get synchronous database session. diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..950b608 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,115 @@ +site_name: fastapi-viewsets +site_description: DRF-style ViewSets for FastAPI — auto-generate CRUD endpoints from SQLAlchemy, Tortoise ORM, or Peewee models in minutes. +site_url: https://svalench.github.io/fastapi_viewsets/ +repo_url: https://github.com/svalench/fastapi_viewsets +repo_name: svalench/fastapi_viewsets +edit_uri: edit/master/docs/ + +docs_dir: docs + +theme: + name: material + language: en + features: + - navigation.tabs + - navigation.sections + - navigation.expand + - navigation.indexes + - navigation.top + - search.suggest + - search.highlight + - content.code.copy + - content.code.annotate + - content.tabs.link + - toc.follow + palette: + - scheme: default + primary: teal + accent: indigo + toggle: + icon: material/brightness-7 + name: Switch to dark mode + - scheme: slate + primary: teal + accent: indigo + toggle: + icon: material/brightness-4 + name: Switch to light mode + font: + text: Inter + code: JetBrains Mono + icon: + repo: fontawesome/brands/github + logo: material/rocket-launch + +nav: + - Home: index.md + - Getting Started: + - Installation: getting-started.md + - Sync Quickstart: quickstart-sync.md + - Async Quickstart: quickstart-async.md + - Guides: + - ORM Adapters: orm-adapters.md + - Eager Loading: eager-loading.md + - Overriding Handlers: overrides.md + - Authentication: authentication.md + - Pagination & Filtering: pagination-filtering.md + - Custom Routes: custom-routes.md + - Reference: + - API Reference: api-reference.md + - Comparison: comparison.md + - Changelog: changelog.md + +plugins: + - search: + lang: + - en + +markdown_extensions: + - abbr + - admonition + - attr_list + - def_list + - footnotes + - md_in_html + - tables + - toc: + permalink: true + - pymdownx.arithmatex: + generic: true + - pymdownx.betterem: + smart_enable: all + - pymdownx.caret + - pymdownx.details + - pymdownx.emoji: + emoji_index: !!python/name:material.extensions.emoji.twemoji + emoji_generator: !!python/name:material.extensions.emoji.to_svg + - pymdownx.highlight: + anchor_linenums: true + line_spans: __span + pygments_lang_class: true + - pymdownx.inlinehilite + - pymdownx.keys + - pymdownx.mark + - pymdownx.smartsymbols + - pymdownx.snippets + - pymdownx.superfences: + custom_fences: + - name: mermaid + class: mermaid + format: !!python/name:pymdownx.superfences.fence_code_format + - pymdownx.tabbed: + alternate_style: true + - pymdownx.tasklist: + custom_checkbox: true + - pymdownx.tilde + +extra: + social: + - icon: fontawesome/brands/github + link: https://github.com/svalench/fastapi_viewsets + - icon: fontawesome/brands/python + link: https://pypi.org/project/fastapi-viewsets/ + +extra_css: + - assets/custom.css diff --git a/pyproject.toml b/pyproject.toml index 15e1922..c6fe18d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,11 +45,13 @@ test = [ "aiosqlite>=0.19.0", ] lint = ["ruff>=0.5", "black>=24", "mypy>=1.8"] +docs = ["mkdocs>=1.6", "mkdocs-material>=9.5", "pymdown-extensions>=10.7"] [project.urls] Homepage = "https://github.com/svalench/fastapi_viewsets" +Documentation = "https://svalench.github.io/fastapi_viewsets/" Issues = "https://github.com/svalench/fastapi_viewsets/issues" -Changelog = "https://github.com/svalench/fastapi_viewsets/blob/main/RELEASE_NOTES.md" +Changelog = "https://github.com/svalench/fastapi_viewsets/blob/master/RELEASE_NOTES.md" [tool.setuptools.packages.find] where = ["."] diff --git a/tests/test_tortoise_lifecycle.py b/tests/test_tortoise_lifecycle.py new file mode 100644 index 0000000..a0dad87 --- /dev/null +++ b/tests/test_tortoise_lifecycle.py @@ -0,0 +1,100 @@ +"""Tests for TortoiseAdapter public lifecycle methods (initialize/close). + +These tests use mocking so they run even when tortoise-orm is not installed +(which is the case in CI — only the [test] extra is installed there). +""" + +import pytest +from unittest.mock import AsyncMock, MagicMock, patch + +from fastapi_viewsets.orm import tortoise_adapter as ta_module +from fastapi_viewsets.orm.tortoise_adapter import TortoiseAdapter + + +@pytest.fixture +def adapter(): + """Create a TortoiseAdapter for testing, mocking TORTOISE_AVAILABLE.""" + with patch.object(ta_module, "TORTOISE_AVAILABLE", True): + adapter = TortoiseAdapter( + database_url="sqlite://:memory:", + models=["tests.models"], + app_label="models", + ) + return adapter + + +def _mock_tortoise(): + """Create a MagicMock that simulates the Tortoise class.""" + mock = MagicMock() + mock.generate_schemas = AsyncMock() + mock.close_connections = AsyncMock() + mock.init = AsyncMock() + return mock + + +@pytest.mark.unit +class TestTortoiseAdapterLifecycle: + """Tests for initialize() and close() public methods.""" + + @pytest.mark.async_test + async def test_initialize_without_schemas(self, adapter): + """initialize() calls _ensure_initialized but not generate_schemas.""" + mock_tortoise = _mock_tortoise() + with patch.object( + adapter, "_ensure_initialized", new=AsyncMock() + ) as mock_init, patch.object( + ta_module, "Tortoise", mock_tortoise, create=True + ): + await adapter.initialize(generate_schemas=False) + mock_init.assert_awaited_once() + mock_tortoise.generate_schemas.assert_not_awaited() + + @pytest.mark.async_test + async def test_initialize_with_schemas(self, adapter): + """initialize(generate_schemas=True) calls both init and generate_schemas.""" + mock_tortoise = _mock_tortoise() + with patch.object( + adapter, "_ensure_initialized", new=AsyncMock() + ) as mock_init, patch.object( + ta_module, "Tortoise", mock_tortoise, create=True + ): + await adapter.initialize(generate_schemas=True) + mock_init.assert_awaited_once() + mock_tortoise.generate_schemas.assert_awaited_once_with(safe=True) + + @pytest.mark.async_test + async def test_close_calls_tortoise_close(self, adapter): + """close() calls Tortoise.close_connections() and resets _initialized.""" + mock_tortoise = _mock_tortoise() + adapter._initialized = True + with patch.object( + ta_module, "Tortoise", mock_tortoise, create=True + ): + await adapter.close() + mock_tortoise.close_connections.assert_awaited_once() + assert adapter._initialized is False + + @pytest.mark.async_test + async def test_close_resets_initialized_even_if_already_false(self, adapter): + """close() works even when adapter was never initialized.""" + mock_tortoise = _mock_tortoise() + assert adapter._initialized is False + with patch.object( + ta_module, "Tortoise", mock_tortoise, create=True + ): + await adapter.close() + mock_tortoise.close_connections.assert_awaited_once() + assert adapter._initialized is False + + @pytest.mark.async_test + async def test_initialize_is_idempotent(self, adapter): + """Calling initialize() twice does not error.""" + mock_tortoise = _mock_tortoise() + with patch.object( + adapter, "_ensure_initialized", new=AsyncMock() + ) as mock_init, patch.object( + ta_module, "Tortoise", mock_tortoise, create=True + ): + await adapter.initialize() + await adapter.initialize() + assert mock_init.await_count == 2