Skip to content

Commit 6187bb2

Browse files
authored
Merge pull request #6 from bali-framework/feature/v0.0.2
v0.0.2
2 parents 00d5d49 + 4d144d0 commit 6187bb2

File tree

14 files changed

+997
-129
lines changed

14 files changed

+997
-129
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,3 +160,6 @@ cython_debug/
160160
.idea/
161161

162162
.vscode
163+
164+
# examples' testing database
165+
database.db

balify/__init__.py

Lines changed: 76 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,48 @@
11
import logging
2+
import humps # noqa
3+
import uuid
24

5+
from collections.abc import AsyncGenerator
36
from datetime import date, datetime # Entity field type
47

5-
import humps # noqa
6-
from fastapi import FastAPI
7-
from fastapi_pagination import add_pagination
8+
from importlib.metadata import version as _version, PackageNotFoundError
9+
from fastapi import FastAPI, Depends, Response, status
10+
from fastapi_pagination import add_pagination, Params
11+
from fastapi_pagination.ext.sqlalchemy import paginate as sa_paginate
12+
from fastapi_users import FastAPIUsers
13+
from fastapi_users.db import SQLAlchemyBaseUserTableUUID, SQLAlchemyUserDatabase
14+
from pydantic_settings import BaseSettings, SettingsConfigDict
15+
from sqlalchemy.orm import DeclarativeBase
16+
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
17+
from sqlmodel import Field, SQLModel, Session, create_engine, select
818

19+
from .decorators import action
920
from .resource import RouterGenerator
10-
from .utils import pluralize
21+
from .utils import pluralize, make_optional_model
1122

12-
from .decorators import action
1323

14-
from sqlmodel import Field, SQLModel, Session, create_engine, select
24+
try:
25+
__version__ = _version("balify")
26+
except PackageNotFoundError:
27+
# fallback for local editable installs or when package metadata not available
28+
__version__ = "0.0.0"
29+
1530

31+
# Constats flags
32+
auth = "auth"
1633

17-
sqlite_file_name = "database.db"
18-
sqlite_url = f"sqlite:///{sqlite_file_name}"
1934

35+
# Read database config from `.env` or envirenment
36+
class Settings(BaseSettings): # type: ignore
37+
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
2038

21-
engine = create_engine(sqlite_url, echo=True)
39+
database_url: str = "sqlite:///database.db"
40+
41+
42+
settings = Settings()
43+
44+
print("--> Read database url: %s" % settings.database_url)
45+
engine = create_engine(settings.database_url, echo=True)
2246

2347

2448
def create_db_and_tables():
@@ -33,14 +57,23 @@ class _OMeta(type):
3357
TODO: Compare `Metaclass` and `__init_subclass__`, then choose one in for `_OMeta`
3458
"""
3559

60+
# TODO: FastAPI-Users liftspan in `auth.py`, just like `add_pagination`
3661
_app = FastAPI()
3762

3863
def __new__(cls, *args, **kwargs):
3964
meta = super().__new__(cls, *args, **kwargs)
4065

41-
meta._app = FastAPI()
66+
print("--> _OMeta _app: %s" % id(cls._app))
67+
4268
meta._app = add_pagination(cls._app)
4369

70+
print("--> add_pagination meta._app: %s" % id(meta._app))
71+
72+
# Register FastAPI-Users routers
73+
from .auth import add_users
74+
75+
meta._app = add_users(meta._app)
76+
4477
return meta
4578

4679
@property
@@ -75,27 +108,31 @@ class O(metaclass=_OMeta):
75108
"""
76109

77110
schema = None # the schema is SQLModel instance
111+
dependencies = [] # router depends
78112

79113
@classmethod
80114
def serve(cls, *entities) -> None:
81-
82-
from fastapi import APIRouter
83-
84-
router = APIRouter()
85-
86-
@router.get("/")
87-
def hello():
88-
return {"Hello": "World", "Powered by": "balify router"}
89-
90-
cls._app.include_router(router, prefix="/router1")
91-
115+
print("--> serve App(%s)" % id(cls._app))
92116
for entity in entities:
93117
print("--> Serve entity `%s` in App(%s)" % (str(entity), id(cls._app)))
94-
cls._app.include_router(entity.as_router(), prefix="/users")
118+
cls._app.include_router(
119+
entity.as_router(), prefix=f"/{pluralize(entity.__name__.lower())}"
120+
)
95121

96122
# Generate all SQLModel schemas to database
97123
create_db_and_tables()
98124

125+
@classmethod
126+
def depends(cls, *args, **kwargs):
127+
"""Depends build-in depends"""
128+
129+
from .auth import current_active_user
130+
131+
if auth in args:
132+
cls.dependencies = [Depends(current_active_user)]
133+
134+
return cls
135+
99136
@action()
100137
def list(self):
101138
"""Generic `list` method
@@ -107,9 +144,8 @@ def list(self):
107144
"""
108145
with Session(engine) as session:
109146
statement = select(self.schema)
110-
targets = session.exec(statement).all()
111-
print("--> Generic list method get targets: %s" % targets)
112-
return targets
147+
# targets = session.exec(statement).all()
148+
return sa_paginate(session, statement, Params(page=1, size=10))
113149

114150
@action()
115151
def get(self, pk=None):
@@ -137,7 +173,10 @@ def create(self, schema_in):
137173
# session.add(schema_in)
138174

139175
# Option 2: Create New schema instance
140-
target = self.schema(**schema_in.model_dump()) # type: ignore
176+
# Why use model_validate?
177+
# Because SQLModel not validate data when `table=True`
178+
# ref: https://github.com/fastapi/sqlmodel/issues/453
179+
target = self.schema.model_validate(schema_in.model_dump()) # type: ignore
141180
session.add(target)
142181
session.commit()
143182
session.refresh(target)
@@ -154,7 +193,9 @@ def update(self, schema_in=None, pk=None):
154193
statement = select(self.schema).where(self.schema.id == pk) # type: ignore
155194
target = session.exec(statement).first()
156195

157-
for k, v in schema_in.model_dump().items(): # type: ignore
196+
optional_model = make_optional_model(self.schema) # type: ignore
197+
optional_schema = optional_model.model_validate(schema_in.model_dump()) # type: ignore
198+
for k, v in optional_schema.model_dump().items(): # type: ignore
158199
if v is not None:
159200
setattr(target, k, v)
160201

@@ -169,10 +210,14 @@ def delete(self, pk=None):
169210
with Session(engine) as session:
170211
statement = select(self.schema).where(self.schema.id == pk) # type: ignore
171212
target = session.exec(statement).first()
172-
session.delete(target)
173-
session.commit()
174-
175-
return {"result": True}
213+
if target:
214+
session.delete(target)
215+
session.commit()
216+
217+
# In previou `bali-core`, it return {"result": True} to compatible with gRPC
218+
# It can be simpler with 204 status response
219+
# return {"result": True}
220+
return Response(status_code=status.HTTP_204_NO_CONTENT)
176221

177222

178223
# I found that `O, o` in `from balify import O, o` look like an cute emontion.

balify/auth.py

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
"""Auth with FastAPI-Users
2+
3+
FastAPI-Users' db, schemas, user managers all in the file.
4+
"""
5+
6+
import uuid
7+
from typing import Optional
8+
9+
from collections.abc import AsyncGenerator
10+
from contextlib import asynccontextmanager
11+
12+
from fastapi import FastAPI, Depends, Request
13+
from fastapi_users import schemas, BaseUserManager, FastAPIUsers, UUIDIDMixin, models
14+
from fastapi_users.authentication import (
15+
AuthenticationBackend,
16+
BearerTransport,
17+
JWTStrategy,
18+
)
19+
from fastapi_users.db import SQLAlchemyBaseUserTableUUID, SQLAlchemyUserDatabase
20+
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
21+
from sqlalchemy.orm import DeclarativeBase
22+
23+
from . import settings
24+
25+
26+
# app
27+
28+
29+
# db
30+
31+
32+
class Base(DeclarativeBase):
33+
pass
34+
35+
36+
class User(SQLAlchemyBaseUserTableUUID, Base):
37+
pass
38+
39+
40+
database_schema_async_maps = [
41+
("sqlite://", "sqlite+aiosqlite://"),
42+
("mysql+pymysql://", "mysql+aiomysql://"),
43+
("postgres://", "postgresql+asyncpg://"),
44+
]
45+
uri = settings.database_url
46+
for sync_schema, async_schema in database_schema_async_maps:
47+
uri = uri.replace(sync_schema, async_schema)
48+
49+
print("--> FastAPI-Users connect to <%s>" % uri)
50+
engine = create_async_engine(uri, echo=True)
51+
async_session_maker = async_sessionmaker(engine, expire_on_commit=False)
52+
53+
54+
async def create_db_and_tables():
55+
async with engine.begin() as conn:
56+
await conn.run_sync(Base.metadata.create_all)
57+
58+
59+
async def get_async_session() -> AsyncGenerator[AsyncSession, None]:
60+
async with async_session_maker() as session:
61+
yield session
62+
63+
64+
async def get_user_db(session: AsyncSession = Depends(get_async_session)):
65+
yield SQLAlchemyUserDatabase(session, User)
66+
67+
68+
# Schemas
69+
70+
71+
class UserRead(schemas.BaseUser[uuid.UUID]):
72+
pass
73+
74+
75+
class UserCreate(schemas.BaseUserCreate):
76+
pass
77+
78+
79+
class UserUpdate(schemas.BaseUserUpdate):
80+
pass
81+
82+
83+
# User Manager
84+
85+
SECRET = "SECRET"
86+
87+
88+
class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
89+
reset_password_token_secret = SECRET
90+
verification_token_secret = SECRET
91+
92+
async def on_after_register(self, user: User, request: Optional[Request] = None):
93+
print(f"User {user.id} has registered.")
94+
95+
async def on_after_forgot_password(
96+
self, user: User, token: str, request: Optional[Request] = None
97+
):
98+
print(f"User {user.id} has forgot their password. Reset token: {token}")
99+
100+
async def on_after_request_verify(
101+
self, user: User, token: str, request: Optional[Request] = None
102+
):
103+
print(f"Verification requested for user {user.id}. Verification token: {token}")
104+
105+
106+
async def get_user_manager(user_db: SQLAlchemyUserDatabase = Depends(get_user_db)):
107+
yield UserManager(user_db)
108+
109+
110+
bearer_transport = BearerTransport(tokenUrl="auth/jwt/login")
111+
112+
113+
def get_jwt_strategy() -> JWTStrategy[models.UP, models.ID]: # type: ignore
114+
return JWTStrategy(secret=SECRET, lifetime_seconds=3600)
115+
116+
117+
auth_backend = AuthenticationBackend(
118+
name="jwt",
119+
transport=bearer_transport,
120+
get_strategy=get_jwt_strategy,
121+
)
122+
123+
fastapi_users = FastAPIUsers[User, uuid.UUID](get_user_manager, [auth_backend]) # type: ignore
124+
125+
current_active_user = fastapi_users.current_user(active=True)
126+
127+
128+
# Register user routers
129+
def add_users(app):
130+
131+
router = app.router
132+
_original_lifespan_context = router.lifespan_context
133+
134+
@asynccontextmanager
135+
async def lifespan(app: FastAPI):
136+
# Not needed if you setup a migration system like Alembic
137+
await create_db_and_tables()
138+
139+
async with _original_lifespan_context(app) as maybe_state:
140+
yield maybe_state
141+
142+
router.lifespan_context = lifespan
143+
144+
app.include_router(
145+
fastapi_users.get_auth_router(auth_backend), prefix="/auth/jwt", tags=["auth"] # type: ignore
146+
)
147+
app.include_router(
148+
fastapi_users.get_register_router(UserRead, UserCreate),
149+
prefix="/auth",
150+
tags=["auth"],
151+
)
152+
app.include_router(
153+
fastapi_users.get_reset_password_router(),
154+
prefix="/auth",
155+
tags=["auth"],
156+
)
157+
app.include_router(
158+
fastapi_users.get_verify_router(UserRead),
159+
prefix="/auth",
160+
tags=["auth"],
161+
)
162+
app.include_router(
163+
fastapi_users.get_users_router(UserRead, UserUpdate),
164+
prefix="/users",
165+
tags=["users"],
166+
)
167+
168+
@app.get("/authenticated-route")
169+
async def authenticated_route(user: User = Depends(current_active_user)):
170+
return {"message": f"Hello {user.email}!"}
171+
172+
return app

balify/cli.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import os
22
import sys
33

4-
import logging
54
import importlib.util
65

76

@@ -41,9 +40,22 @@ def _load_app_from_main(path: Path):
4140
raise RuntimeError("main.py found but no 'app' variable inside")
4241

4342

44-
@app.callback()
45-
def callback():
43+
@app.callback(invoke_without_command=True)
44+
def callback(
45+
ctx: typer.Context,
46+
version: bool = typer.Option(
47+
False, "--version", "-v", help="Show version and exit"
48+
),
49+
):
4650
"""Start Balify App"""
51+
if version:
52+
from . import __version__
53+
54+
typer.echo(__version__)
55+
raise typer.Exit()
56+
# If invoked without subcommand, keep going (Typer will call subcommands)
57+
if ctx.invoked_subcommand is None:
58+
return
4759

4860

4961
@app.command()

0 commit comments

Comments
 (0)