Skip to content

Commit 0d8da09

Browse files
committed
switch to using odmantic
1 parent d35fcc4 commit 0d8da09

File tree

19 files changed

+70
-176
lines changed

19 files changed

+70
-176
lines changed

README.md

+5-2
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ Accelerate your next web development project with this FastAPI/React/MongoDB bas
44

55
This project is for developers looking to build and maintain full-feature progressive web applications using Python on the backend / Typescript on the frontend, and want the complex-but-routine aspects of auth 'n auth, and component and deployment configuration, taken care of, including interactive API documentation.
66

7-
This is an **experimental** fork of [Sebastián Ramírez's](https://github.com/tiangolo) [Full Stack FastAPI and PostgreSQL Base Project Generator](https://github.com/tiangolo/full-stack-fastapi-postgresql) and [Whythawk's](https://github.com/whythawk) [Full Stack FastAPI and PostgreSQL Base Project Generator](https://github.com/whythawk/full-stack-fastapi-postgresql). FastAPI is updated to version 0.103.2, MongoDB Motor 3.4, Beanie ODM 1.23, and the frontend to React.
7+
This is an **experimental** fork of [Sebastián Ramírez's](https://github.com/tiangolo) [Full Stack FastAPI and PostgreSQL Base Project Generator](https://github.com/tiangolo/full-stack-fastapi-postgresql) and [Whythawk's](https://github.com/whythawk) [Full Stack FastAPI and PostgreSQL Base Project Generator](https://github.com/whythawk/full-stack-fastapi-postgresql). FastAPI is updated to version 0.103.2, MongoDB Motor 3.4, ODMantic ODM 1.0.0, and the frontend to React.
88

99

1010
- [Screenshots](#screenshots)
@@ -50,7 +50,7 @@ This FastAPI, React, MongoDB repo will generate a complete web application stack
5050
- **Authentication** user management schemas, models, crud and apis already built, with OAuth2 JWT token support & default hashing. Offers _magic link_ authentication, with password fallback, with cookie management, including `access` and `refresh` tokens.
5151
- [**FastAPI**](https://github.com/tiangolo/fastapi) backend with [Inboard](https://inboard.bws.bio/) one-repo Docker images:
5252
- **MongoDB Motor** https://motor.readthedocs.io/en/stable/
53-
- **MongoDB Beanie** for handling ODM creation https://beanie-odm.dev/
53+
- **MongoDB ODMantic** for handling ODM creation https://art049.github.io/odmantic/
5454
- **Common CRUD** support via generic inheritance.
5555
- **Standards-based**: Based on (and fully compatible with) the open standards for APIs: [OpenAPI](https://github.com/OAI/OpenAPI-Specification) and [JSON Schema](http://json-schema.org/).
5656
- [**Many other features**]("https://fastapi.tiangolo.com/features/"): including automatic validation, serialization, interactive documentation, etc.
@@ -90,6 +90,9 @@ This stack is in an experimental state, so there is no guarantee for bugs or iss
9090

9191
See notes:
9292

93+
## CalVer 2023.12.XX
94+
- Replaced Beanie usage with ODMantic
95+
9396
## CalVer 2023.11.10
9497

9598
- Replaced Next/Vue.js frontend framework with entirely React/Redux

docs/getting-started.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ This FastAPI, React, MongoDB repo will generate a complete web application stack
2525
- **Authentication** user management schemas, models, crud and apis already built, with OAuth2 JWT token support & default hashing. Offers _magic link_ authentication, with password fallback, with cookie management, including `access` and `refresh` tokens.
2626
- [**FastAPI**](https://github.com/tiangolo/fastapi) backend with [Inboard](https://inboard.bws.bio/) one-repo Docker images:
2727
- **Mongo Motor** https://motor.readthedocs.io/en/stable/
28-
- **Mongo Beanie** for handling ODM creation https://beanie-odm.dev/
28+
- **MongoDB ODMantic** for handling ODM creation https://art049.github.io/odmantic/
2929
- **Common CRUD** support via generic inheritance.
3030
- **Standards-based**: Based on (and fully compatible with) the open standards for APIs: [OpenAPI](https://github.com/OAI/OpenAPI-Specification) and [JSON Schema](http://json-schema.org/).
3131
- [**Many other features**]("https://fastapi.tiangolo.com/features/"): including automatic validation, serialization, interactive documentation, etc.
Binary file not shown.

{{cookiecutter.project_slug}}/backend/app/README.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ Next, open your editor at `./backend/app/` (instead of the project root: `./`),
9292
$ code .
9393
```
9494

95-
Modify or add beanie models in `./backend/app/app/models/` (make sure to include them in `MODELS` within `.backend/app/app/models/__init__.py`), Pydantic schemas in `./backend/app/app/schemas/`, API endpoints in `./backend/app/app/api/`, CRUD (Create, Read, Update, Delete) utils in `./backend/app/app/crud/`. The easiest might be to copy the ones for Items (models, endpoints, and CRUD utils) and update them to your needs.
95+
Modify or add odmantic models in `./backend/app/app/models/` (make sure to include them in `MODELS` within `.backend/app/app/models/__init__.py`), Pydantic schemas in `./backend/app/app/schemas/`, API endpoints in `./backend/app/app/api/`, CRUD (Create, Read, Update, Delete) utils in `./backend/app/app/crud/`. The easiest might be to copy the ones for Items (models, endpoints, and CRUD utils) and update them to your needs.
9696

9797
Add and modify tasks to the Celery worker in `./backend/app/app/worker.py`.
9898

{{cookiecutter.project_slug}}/backend/app/app/crud/base.py

+9-6
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@
33
from fastapi.encoders import jsonable_encoder
44
from pydantic import BaseModel
55
from motor.core import AgnosticDatabase
6+
from odmantic import AIOEngine
67

78
from app.db.base_class import Base
89
from app.core.config import settings
10+
from app.db.session import get_engine
911

1012
ModelType = TypeVar("ModelType", bound=Base)
1113
CreateSchemaType = TypeVar("CreateSchemaType", bound=BaseModel)
@@ -23,18 +25,19 @@ def __init__(self, model: Type[ModelType]):
2325
* `schema`: A Pydantic model (schema) class
2426
"""
2527
self.model = model
28+
self.engine: AIOEngine = get_engine()
2629

2730
async def get(self, db: AgnosticDatabase, id: Any) -> Optional[ModelType]:
28-
return await self.model.get(id)
31+
return await self.engine.find_one(self.model, self.model.id == id)
2932

3033
async def get_multi(self, db: AgnosticDatabase, *, page: int = 0, page_break: bool = False) -> list[ModelType]:
3134
offset = {"skip": page * settings.MULTI_MAX, "limit": settings.MULTI_MAX} if page_break else {}
32-
return await self.model.find_all(**offset).to_list()
35+
return await self.engine.find(self.model, **offset)
3336

3437
async def create(self, db: AgnosticDatabase, *, obj_in: CreateSchemaType) -> ModelType:
3538
obj_in_data = jsonable_encoder(obj_in)
3639
db_obj = self.model(**obj_in_data) # type: ignore
37-
return await db_obj.create()
40+
return await self.engine.save(db_obj)
3841

3942
async def update(
4043
self, db: AgnosticDatabase, *, db_obj: ModelType, obj_in: Union[UpdateSchemaType, Dict[str, Any]]
@@ -43,16 +46,16 @@ async def update(
4346
if isinstance(obj_in, dict):
4447
update_data = obj_in
4548
else:
46-
update_data = obj_in.dict(exclude_unset=True)
49+
update_data = obj_in.model_dump(exclude_unset=True)
4750
for field in obj_data:
4851
if field in update_data:
4952
setattr(db_obj, field, update_data[field])
5053
# TODO: Check if this saves changes with the setattr calls
51-
await db_obj.save()
54+
await self.engine.save(db_obj)
5255
return db_obj
5356

5457
async def remove(self, db: AgnosticDatabase, *, id: int) -> ModelType:
5558
obj = await self.model.get(id)
5659
if obj:
57-
await obj.delete()
60+
await self.engine.delete(obj)
5861
return obj
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
from __future__ import annotations
22
from motor.core import AgnosticDatabase
3-
from beanie import WriteRules
4-
from beanie.operators import And, In, PullAll
53

64
from app.crud.base import CRUDBase
75
from app.models import User, Token
@@ -12,27 +10,31 @@
1210
class CRUDToken(CRUDBase[Token, RefreshTokenCreate, RefreshTokenUpdate]):
1311
# Everything is user-dependent
1412
async def create(self, db: AgnosticDatabase, *, obj_in: str, user_obj: User) -> Token:
15-
db_obj = await self.model.find_one(self.model.token == obj_in)
13+
db_obj = await self.engine.find_one(self.model, self.model.token == obj_in)
1614
if db_obj:
1715
if db_obj.authenticates_id != user_obj.id:
1816
raise ValueError("Token mismatch between key and user.")
1917
return db_obj
2018
else:
21-
new_token = self.model(token=obj_in, authenticates_id=user_obj.id)
22-
user_obj.refresh_tokens.append(new_token)
23-
await user_obj.save(link_rule=WriteRules.WRITE)
19+
new_token = self.model(token=obj_in, authenticates_id=user_obj)
20+
user_obj.refresh_tokens.append(new_token.id)
21+
await self.engine.save_all([new_token, user_obj])
2422
return new_token
2523

2624
async def get(self, *, user: User, token: str) -> Token:
27-
return await user.find_one(And(User.id == user.id, User.refresh_tokens.token == token), fetch_links=True)
25+
return await self.engine.find_one(User, ((User.id == user.id) & (User.refresh_tokens.token == token)))
2826

2927
async def get_multi(self, *, user: User, page: int = 0, page_break: bool = False) -> list[Token]:
3028
offset = {"skip": page * settings.MULTI_MAX, "limit": settings.MULTI_MAX} if page_break else {}
31-
return await User.find(In(User.refresh_tokens, user.refresh_tokens), **offset).to_list()
29+
return await self.engine.find(User, (User.refresh_tokens.in_([user.refresh_tokens])), **offset)
3230

3331
async def remove(self, db: AgnosticDatabase, *, db_obj: Token) -> None:
34-
await User.update_all(PullAll({User.refresh_tokens: [db_obj.to_ref()]}))
35-
await db_obj.delete()
32+
users = []
33+
async for user in self.engine.find(User, User.refresh_tokens.in_([db_obj.id])):
34+
user.refresh_tokens.remove(db_obj.id)
35+
users.append(user)
36+
await self.engine.save(users)
37+
await self.engine.delete(db_obj)
3638

3739

3840
token = CRUDToken(Token)

{{cookiecutter.project_slug}}/backend/app/app/crud/crud_user.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
# ODM, Schema, Schema
1313
class CRUDUser(CRUDBase[User, UserCreate, UserUpdate]):
1414
async def get_by_email(self, db: AgnosticDatabase, *, email: str) -> Optional[User]:
15-
return await User.find_one(User.email == email)
15+
return await self.engine.find_one(User, User.email == email)
1616

1717
async def create(self, db: AgnosticDatabase, *, obj_in: UserCreate) -> User:
1818
# TODO: Figure out what happens when you have a unique key like 'email'
@@ -24,7 +24,7 @@ async def create(self, db: AgnosticDatabase, *, obj_in: UserCreate) -> User:
2424
"is_superuser": obj_in.is_superuser,
2525
}
2626

27-
return await User(**user).create()
27+
return await self.engine.save(User(**user))
2828

2929
async def update(self, db: AgnosticDatabase, *, db_obj: User, obj_in: Union[UserUpdate, Dict[str, Any]]) -> User:
3030
if isinstance(obj_in, dict):
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
1-
from beanie import Document
1+
from odmantic import Model
22

3-
Base = Document
3+
Base = Model

{{cookiecutter.project_slug}}/backend/app/app/db/init_db.py

-3
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,10 @@
11
from pymongo.database import Database
2-
from beanie import init_beanie
32

43
from app import crud, schemas
54
from app.core.config import settings
6-
from app.models import MODELS
75

86

97
async def init_db(db: Database) -> None:
10-
await init_beanie(db, document_models=MODELS)
118
user = await crud.user.get_by_email(db, email=settings.FIRST_SUPERUSER)
129
if not user:
1310
# Create user auth

{{cookiecutter.project_slug}}/backend/app/app/db/session.py

+10-3
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,34 @@
11
from app.core.config import settings
22
from app.__version__ import __version__
33
from motor import motor_asyncio, core
4+
from odmantic import AIOEngine
45
from pymongo.driver_info import DriverInfo
56

6-
DRIVER_INFO=DriverInfo(name="full-stack-fastapi-mongodb", version=__version__)
7+
DRIVER_INFO = DriverInfo(name="full-stack-fastapi-mongodb", version=__version__)
78

89

910
class _MongoClientSingleton:
1011
mongo_client: motor_asyncio.AsyncIOMotorClient | None
12+
engine: AIOEngine
1113

1214
def __new__(cls):
1315
if not hasattr(cls, "instance"):
1416
cls.instance = super(_MongoClientSingleton, cls).__new__(cls)
1517
cls.instance.mongo_client = motor_asyncio.AsyncIOMotorClient(
16-
settings.MONGO_DATABASE_URI,
17-
driver=DRIVER_INFO
18+
settings.MONGO_DATABASE_URI, driver=DRIVER_INFO
19+
)
20+
cls.instance.engine = AIOEngine(
21+
client=cls.instance.mongo_client,
22+
database=settings.MONGO_DATABASE
1823
)
1924
return cls.instance
2025

2126

2227
def MongoDatabase() -> core.AgnosticDatabase:
2328
return _MongoClientSingleton().mongo_client[settings.MONGO_DATABASE]
2429

30+
def get_engine() -> AIOEngine:
31+
return _MongoClientSingleton().engine
2532

2633
async def ping():
2734
await MongoDatabase().command("ping")

{{cookiecutter.project_slug}}/backend/app/app/main.py

-4
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,13 @@
11
from fastapi import FastAPI
22
from starlette.middleware.cors import CORSMiddleware
3-
from beanie import init_beanie
43
from contextlib import asynccontextmanager
54

65
from app.api.api_v1.api import api_router
76
from app.core.config import settings
8-
from app.db.session import MongoDatabase
9-
from app.models import MODELS
107

118

129
@asynccontextmanager
1310
async def app_init(app: FastAPI):
14-
await init_beanie(MongoDatabase(), document_models=MODELS)
1511
app.include_router(api_router, prefix=settings.API_V1_STR)
1612
yield
1713

Original file line numberDiff line numberDiff line change
@@ -1,8 +1,2 @@
1-
from beanie import Document
21
from .user import User
32
from .token import Token
4-
5-
MODELS: list[Document] = [User, Token]
6-
7-
for model in MODELS:
8-
model.model_rebuild()
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,13 @@
11
from __future__ import annotations
22

3-
from typing import TYPE_CHECKING
4-
from beanie.odm.fields import PydanticObjectId
3+
from odmantic import Reference
54

65
from app.db.base_class import Base
76

8-
if TYPE_CHECKING:
9-
from .user import User # noqa: F401
7+
from .user import User # noqa: F401
108

119

1210
# Consider reworking to consolidate information to a userId. This may not work well
1311
class Token(Base):
1412
token: str
15-
authenticates_id: PydanticObjectId
13+
authenticates_id: User = Reference()

{{cookiecutter.project_slug}}/backend/app/app/models/user.py

+6-6
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
from __future__ import annotations
2-
from typing import TYPE_CHECKING, Optional
2+
from typing import TYPE_CHECKING, Any, Optional
33
from datetime import datetime
4-
from pydantic import Field, EmailStr
5-
from beanie.odm.fields import Link
4+
from pydantic import EmailStr
5+
from odmantic import ObjectId, Field
66

77
from app.db.base_class import Base
88

@@ -19,10 +19,10 @@ class User(Base):
1919
modified: datetime = Field(default_factory=datetime_now_sec)
2020
full_name: str = Field(default="")
2121
email: EmailStr
22-
hashed_password: Optional[str]
23-
totp_secret: Optional[str] = Field(default=None)
22+
hashed_password: Any = Field(default=None)
23+
totp_secret: Any = Field(default=None)
2424
totp_counter: Optional[int] = Field(default=None)
2525
email_validated: bool = Field(default=False)
2626
is_active: bool = Field(default=False)
2727
is_superuser: bool = Field(default=False)
28-
refresh_tokens: list[Link["Token"]] = Field(default_factory=list)
28+
refresh_tokens: list[ObjectId] = Field(default_factory=list)

{{cookiecutter.project_slug}}/backend/app/app/schemas/token.py

+6-7
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,15 @@
11
from typing import Optional
22
from pydantic import BaseModel
3-
from beanie.odm.fields import PydanticObjectId
4-
from beanie import Document
3+
from odmantic import Model, ObjectId
54

65

76
class RefreshTokenBase(BaseModel):
87
token: str
9-
authenticates: Optional[Document] = None
8+
authenticates: Optional[Model] = None
109

1110

1211
class RefreshTokenCreate(RefreshTokenBase):
13-
authenticates: Document
12+
authenticates: Model
1413

1514

1615
class RefreshTokenUpdate(RefreshTokenBase):
@@ -29,14 +28,14 @@ class Token(BaseModel):
2928

3029

3130
class TokenPayload(BaseModel):
32-
sub: Optional[PydanticObjectId] = None
31+
sub: Optional[ObjectId] = None
3332
refresh: Optional[bool] = False
3433
totp: Optional[bool] = False
3534

3635

3736
class MagicTokenPayload(BaseModel):
38-
sub: Optional[PydanticObjectId] = None
39-
fingerprint: Optional[PydanticObjectId] = None
37+
sub: Optional[ObjectId] = None
38+
fingerprint: Optional[ObjectId] = None
4039

4140

4241
class WebToken(BaseModel):

{{cookiecutter.project_slug}}/backend/app/app/schemas/user.py

+7-6
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from typing import Optional
2-
from pydantic import BaseModel, Field, EmailStr, constr, field_validator
3-
from beanie import PydanticObjectId
2+
from typing_extensions import Annotated
3+
from pydantic import BaseModel, Field, EmailStr, StringConstraints, field_validator
4+
from odmantic import ObjectId
45

56

67
class UserLogin(BaseModel):
@@ -20,17 +21,17 @@ class UserBase(BaseModel):
2021
# Properties to receive via API on creation
2122
class UserCreate(UserBase):
2223
email: EmailStr
23-
password: Optional[constr(min_length=8, max_length=64)] = None
24+
password: Optional[Annotated[str, StringConstraints(min_length=8, max_length=64)]] = None
2425

2526

2627
# Properties to receive via API on update
2728
class UserUpdate(UserBase):
28-
original: Optional[constr(min_length=8, max_length=64)] = None
29-
password: Optional[constr(min_length=8, max_length=64)] = None
29+
original: Optional[Annotated[str, StringConstraints(min_length=8, max_length=64)]] = None
30+
password: Optional[Annotated[str, StringConstraints(min_length=8, max_length=64)]] = None
3031

3132

3233
class UserInDBBase(UserBase):
33-
id: Optional[PydanticObjectId] = None
34+
id: Optional[ObjectId] = None
3435

3536
class Config:
3637
from_attributes = True

0 commit comments

Comments
 (0)