Skip to content

Commit 368aa7d

Browse files
authored
Merge pull request #7 from svalench/docs/async-pydantic2-readme
docs(readme): async + Pydantic v2 quickstart, override LIST and POST examples
2 parents f649be1 + 13ce2c8 commit 368aa7d

1 file changed

Lines changed: 208 additions & 0 deletions

File tree

README.md

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,214 @@ if __name__ == "__main__":
9090

9191
`GET /items` returns `200` with a JSON list (possibly empty). Use `POST /items` with `{"name": "apple"}` to create rows.
9292

93+
## Async quickstart (SQLAlchemy 2.x + Pydantic v2)
94+
95+
`AsyncBaseViewset` mirrors `BaseViewset` but every CRUD handler is
96+
`async`, backed by an async SQLAlchemy `AsyncSession`. Install an async
97+
driver alongside the package:
98+
99+
```bash
100+
pip install "fastapi-viewsets[sqlalchemy]" aiosqlite
101+
```
102+
103+
Point `SQLALCHEMY_DATABASE_URL` (or `SQLALCHEMY_ASYNC_DATABASE_URL`) at
104+
an async-capable URL and use the lazy helpers from `db_conf`. The
105+
package auto-converts `sqlite://` to `sqlite+aiosqlite://`,
106+
`postgresql://` to `postgresql+asyncpg://`, etc.
107+
108+
```python
109+
from fastapi import FastAPI
110+
from pydantic import BaseModel, ConfigDict
111+
from sqlalchemy import Column, Integer, String
112+
113+
from fastapi_viewsets import AsyncBaseViewset
114+
from fastapi_viewsets.db_conf import (
115+
Base,
116+
async_engine,
117+
get_async_session,
118+
)
119+
120+
app = FastAPI()
121+
122+
123+
class Item(Base):
124+
"""Async-friendly SQLAlchemy model."""
125+
126+
__tablename__ = "items_async"
127+
id = Column(Integer, primary_key=True)
128+
name = Column(String(255), nullable=False)
129+
130+
131+
class ItemSchema(BaseModel):
132+
"""Pydantic v2 schema reused as request and response model."""
133+
134+
model_config = ConfigDict(from_attributes=True)
135+
id: int | None = None
136+
name: str
137+
138+
139+
@app.on_event("startup")
140+
async def _create_tables() -> None:
141+
"""Create tables once on startup using the async engine."""
142+
async with async_engine.begin() as conn:
143+
await conn.run_sync(Base.metadata.create_all)
144+
145+
146+
items = AsyncBaseViewset(
147+
endpoint="/items",
148+
model=Item,
149+
response_model=ItemSchema,
150+
db_session=get_async_session,
151+
tags=["items"],
152+
)
153+
items.register(methods=["LIST", "GET", "POST", "PATCH", "DELETE"])
154+
app.include_router(items)
155+
```
156+
157+
Notes:
158+
159+
- Pydantic v2 is required (`pydantic>=2.5`). Use
160+
`model_config = ConfigDict(from_attributes=True)` instead of the v1
161+
`class Config: orm_mode = True`.
162+
- `PATCH` uses `model_dump(exclude_unset=True)` internally, so unset
163+
fields are no longer overwritten with defaults.
164+
- If the async driver (`aiosqlite` / `asyncpg` / `aiomysql`) is not
165+
installed, sync usage still works — only `get_async_session()` raises
166+
a helpful `RuntimeError`.
167+
168+
## Overriding `list` and `create_element` (custom LIST and POST)
169+
170+
Every CRUD handler is a regular method, so subclassing the viewset is
171+
the canonical way to add filtering, ordering, validation, conflict
172+
handling, and so on. The example below subclasses `AsyncBaseViewset`
173+
and overrides both `list` (case-insensitive search + simple ordering)
174+
and `create_element` (input normalization + map `IntegrityError` to
175+
409).
176+
177+
```python
178+
from typing import List, Optional
179+
180+
from fastapi import Body, HTTPException, status
181+
from pydantic import BaseModel, ConfigDict, Field
182+
from sqlalchemy import Column, DateTime, Integer, String, func, select
183+
from sqlalchemy.exc import IntegrityError
184+
from sqlalchemy.ext.asyncio import AsyncSession
185+
186+
from fastapi_viewsets import AsyncBaseViewset
187+
from fastapi_viewsets.db_conf import Base, get_async_session
188+
189+
190+
class Item(Base):
191+
"""Item model with timestamps and a unique name."""
192+
193+
__tablename__ = "items_custom"
194+
id = Column(Integer, primary_key=True)
195+
name = Column(String(255), nullable=False, unique=True, index=True)
196+
description = Column(String(1024), nullable=True)
197+
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
198+
199+
200+
class ItemSchema(BaseModel):
201+
"""Single Pydantic v2 schema reused as request and response model.
202+
203+
Server-controlled fields (``id``, ``created_at``) are optional so
204+
the same schema can be used for POST/PATCH bodies and responses
205+
— ``register()`` patches the body annotation to ``response_model``.
206+
"""
207+
208+
model_config = ConfigDict(from_attributes=True, str_strip_whitespace=True)
209+
id: Optional[int] = None
210+
name: str = Field(..., min_length=1, max_length=255)
211+
description: Optional[str] = Field(default=None, max_length=1024)
212+
created_at: Optional[object] = None # datetime in real code
213+
214+
215+
class ItemsViewSet(AsyncBaseViewset):
216+
"""Custom async viewset that overrides LIST and POST."""
217+
218+
async def list( # type: ignore[override]
219+
self,
220+
limit: int = 20,
221+
offset: int = 0,
222+
search: Optional[str] = None,
223+
order_by: str = "-created_at",
224+
token: Optional[str] = None,
225+
) -> List[ItemSchema]:
226+
"""Custom LIST: case-insensitive search + whitelist ordering.
227+
228+
Query: ``GET /items?search=foo&order_by=-name&limit=10``.
229+
"""
230+
session: AsyncSession = self.db_session()
231+
try:
232+
stmt = select(self.model)
233+
if search:
234+
stmt = stmt.where(self.model.name.ilike(f"%{search}%"))
235+
236+
# "-name" → desc, "name" → asc; whitelist allowed columns.
237+
field, desc = (order_by[1:], True) if order_by.startswith("-") else (order_by, False)
238+
column = {"name": self.model.name, "created_at": self.model.created_at}.get(field)
239+
if column is None:
240+
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Unsupported order_by")
241+
stmt = stmt.order_by(column.desc() if desc else column.asc())
242+
stmt = stmt.offset(offset).limit(limit)
243+
244+
rows = (await session.execute(stmt)).scalars().all()
245+
return [ItemSchema.model_validate(row) for row in rows]
246+
finally:
247+
await session.close()
248+
249+
async def create_element( # type: ignore[override]
250+
self,
251+
item: ItemSchema = Body(...),
252+
token: Optional[str] = None,
253+
) -> ItemSchema:
254+
"""Custom POST: normalize, persist, map IntegrityError to 409."""
255+
# Pydantic v2 dump; ``str_strip_whitespace`` already trimmed strings.
256+
payload = item.model_dump(exclude_unset=True, exclude={"id", "created_at"})
257+
258+
session: AsyncSession = self.db_session()
259+
try:
260+
obj = self.model(**payload)
261+
session.add(obj)
262+
try:
263+
await session.commit()
264+
except IntegrityError as exc:
265+
await session.rollback()
266+
raise HTTPException(
267+
status.HTTP_409_CONFLICT,
268+
f"Item '{payload.get('name')}' already exists",
269+
) from exc
270+
await session.refresh(obj)
271+
return ItemSchema.model_validate(obj)
272+
finally:
273+
await session.close()
274+
275+
276+
items = ItemsViewSet(
277+
endpoint="/items",
278+
model=Item,
279+
response_model=ItemSchema,
280+
db_session=get_async_session,
281+
tags=["items"],
282+
)
283+
items.register(methods=["LIST", "GET", "POST", "PATCH", "DELETE"])
284+
```
285+
286+
Key points when overriding:
287+
288+
- **Keep the method names and the `item` body parameter.** `register()`
289+
introspects `list`, `get_element`, `create_element`,
290+
`update_element`, `delete_element`. It also rewrites the
291+
``item.__annotation__`` to `response_model` so the OpenAPI body
292+
schema stays consistent — use the same schema for request and
293+
response, or pre-validate inside the handler.
294+
- **Adding new query parameters is fine** (`search`, `order_by`,
295+
filters, etc.); FastAPI picks them up automatically.
296+
- **Manage your own session lifecycle** in overrides (`try/finally` +
297+
`await session.close()`) or use a FastAPI dependency with `yield`.
298+
- For sync apps, the same pattern applies to `BaseViewset` — just drop
299+
the `async`/`await` and use `Session` instead of `AsyncSession`.
300+
93301
## Authentication example
94302

95303
`register()` accepts `OAuth2PasswordBearer` plus a list of logical operations (`POST`, `PUT`, …) that require a bearer token.

0 commit comments

Comments
 (0)