Skip to content

Commit

Permalink
Transform to async server
Browse files Browse the repository at this point in the history
  • Loading branch information
thyb-zytek committed May 23, 2024
1 parent 031ca6f commit aafc200
Show file tree
Hide file tree
Showing 39 changed files with 1,414 additions and 726 deletions.
16 changes: 9 additions & 7 deletions app/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,26 @@ FROM python:3.12

WORKDIR /app/

ARG ENV

# Env setup
ARG ENV
ENV LANG C.UTF-8
ENV LC_ALL C.UTF-8
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONFAULTHANDLER 1
ENV PYTHONPATH=/app

# Update and install usefull package
RUN pip install --no-cache-dir poetry
RUN pip install --no-cache-dir poetry
RUN poetry config virtualenvs.create false # Needed to Github Actions

# Setup app environment
COPY . .
RUN poetry install --no-root --with dev
RUN chmod +x ./scripts/setup_database.sh
RUN ./scripts/setup_database.sh
RUN if [ "$ENV" = "production" ]; then \
poetry install --no-root --without dev ;\
else \
poetry install --no-root --with dev; \
fi
RUN chmod +x ./scripts/start.sh

# Launch app
CMD fastapi dev main.py
CMD ["./scripts/start.sh"]
35 changes: 23 additions & 12 deletions app/alembic/env.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import asyncio
import os
from logging.config import fileConfig

from sqlalchemy import engine_from_config, pool
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config

from alembic import context # noqa

Expand Down Expand Up @@ -31,7 +34,7 @@ def get_url():
server = os.getenv("POSTGRES_SERVER", "db")
port = os.getenv("POSTGRES_PORT", "5432")
db = os.getenv("POSTGRES_DB", "app")
return f"postgresql+psycopg://{user}:{password}@{server}:{port}/{db}"
return f"postgresql+asyncpg://{user}:{password}@{server}:{port}/{db}"


def run_migrations_offline() -> None:
Expand All @@ -55,28 +58,36 @@ def run_migrations_offline() -> None:
context.run_migrations()


def run_migrations_online() -> None:
"""Run migrations in 'online' mode.
def do_run_migrations(connection: Connection) -> None:
context.configure(connection=connection, target_metadata=target_metadata)

with context.begin_transaction():
context.run_migrations()


In this scenario we need to create an Engine
async def run_async_migrations() -> None:
"""In this scenario we need to create an Engine
and associate a connection with the context.
"""
configuration = config.get_section(config.config_ini_section)
configuration["sqlalchemy.url"] = get_url()
connectable = engine_from_config(
connectable = async_engine_from_config(
configuration,
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)

with connectable.connect() as connection:
context.configure(
connection=connection, target_metadata=target_metadata, compare_type=True
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)

await connectable.dispose()


def run_migrations_online() -> None:
"""Run migrations in 'online' mode."""

with context.begin_transaction():
context.run_migrations()
asyncio.run(run_async_migrations())


if context.is_offline_mode():
Expand Down
1 change: 1 addition & 0 deletions app/alembic/script.py.mako
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa
import sqlmodel.sql.sqltypes
${imports if imports else ""}

# revision identifiers, used by Alembic.
Expand Down
68 changes: 68 additions & 0 deletions app/alembic/versions/2ee47aeec44a_create_models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
"""create models
Revision ID: 2ee47aeec44a
Revises:
Create Date: 2024-05-16 09:07:15.843614
"""

from typing import Sequence, Union

import sqlalchemy as sa
import sqlmodel.sql.sqltypes

from alembic import op

# revision identifiers, used by Alembic.
revision: str = "2ee47aeec44a"
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"user",
sa.Column("uid", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("name", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("email", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.PrimaryKeyConstraint("uid"),
)
op.create_table(
"account",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("name", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("image", sa.String(), nullable=True),
sa.Column("color", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("creator_id", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.ForeignKeyConstraint(
["creator_id"],
["user.uid"],
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("creator_id", "name", name="unique_name_by_creator"),
)
op.create_table(
"useraccountlink",
sa.Column("user_id", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("account_id", sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(
["account_id"],
["account.id"],
),
sa.ForeignKeyConstraint(
["user_id"],
["user.uid"],
),
sa.PrimaryKeyConstraint("user_id", "account_id"),
)
# ### end Alembic commands ###


def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table("useraccountlink")
op.drop_table("account")
op.drop_table("user")
# ### end Alembic commands ###
23 changes: 21 additions & 2 deletions app/api/authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,16 @@
from fastapi import APIRouter, Request
from oauthlib.oauth2.rfc6749.errors import InvalidClientIdError, InvalidGrantError

from core.authentication import (
FirebaseToken,
GoogleAuthorizationUrl,
GoogleToken,
RefreshTokenPayload,
UserSignIn,
)
from core.config import settings
from core.dependencies import GoogleOAuthFlowDep
from core.exception import FirebaseAuthError, FirebaseException
from models.authentication import FirebaseToken, GoogleAuthorizationUrl, UserSignIn
from core.exceptions import FirebaseAuthError, FirebaseException

router = APIRouter()

Expand Down Expand Up @@ -50,3 +56,16 @@ async def auth_google(code: str, flow: GoogleOAuthFlowDep, request: Request) ->
return response.json()
except (InvalidGrantError, InvalidClientIdError, httpx.HTTPStatusError):
raise FirebaseException()


@router.post("/refresh", response_model=GoogleToken, response_model_by_alias=False)
async def refresh_token(payload: RefreshTokenPayload) -> Any:
try:
response = httpx.post(
f"https://securetoken.googleapis.com/v1/token?key={settings.FIREBASE_APIKEY}",
data={"grant_type": "refresh_token", **payload.model_dump()},
)
response.raise_for_status()
return response.json()
except httpx.HTTPStatusError:
raise FirebaseAuthError(detail="Invalid refresh token")
16 changes: 14 additions & 2 deletions app/api/main.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,21 @@
import logging

from fastapi import APIRouter
from sqlalchemy import text

from core.dependencies import SessionDep

router = APIRouter()

logger = logging.getLogger("budgly")


@router.get("/healthcheck")
def healthcheck() -> str:
"""Check if server is up."""
async def healthcheck(session: SessionDep) -> str:
"""Check if server is up and DB is reachable."""
try:
await session.exec(text("SELECT 1"))
except Exception as e:
logger.error(e)
return "KO"
return "OK"
5 changes: 3 additions & 2 deletions app/api/v1/__init__.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
from fastapi import APIRouter

from . import users
from . import accounts, users

__version__ = 1

router = APIRouter()

router.include_router(users.router, tags=["V1.User"], prefix="/users")
router.include_router(users.router, tags=["V1|User"], prefix="/users")
router.include_router(accounts.router, tags=["V1|Account"], prefix="/accounts")
11 changes: 6 additions & 5 deletions app/api/v1/users.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
from fastapi import APIRouter

from core.dependencies import FirebaseUserDep
from models.user import FirebaseUser
from core.dependencies import FirebaseUserDep, SessionDep
from crud.user import get_or_create_user
from models.user import User

router = APIRouter()


@router.get("/me", response_model=FirebaseUser)
def get_firebase_user(user: FirebaseUserDep) -> FirebaseUser:
return user
@router.get("/me", response_model=User)
async def get_user(firebase_user: FirebaseUserDep, session: SessionDep) -> User:
return await get_or_create_user(firebase_user, session)
63 changes: 62 additions & 1 deletion app/core/authentication.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,46 @@
import logging
from functools import lru_cache
from typing import Annotated

import firebase_admin # type: ignore
from google_auth_oauthlib.flow import Flow # type: ignore
from fastapi import Depends, Request
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from firebase_admin import auth # type: ignore
from google_auth_oauthlib.flow import Flow # type: ignore # type: ignore
from pydantic import BaseModel, EmailStr, Field, HttpUrl
from starlette.datastructures import URL

from core.config import settings
from core.exceptions import ExpiredToken, InvalidToken, ServerError
from models import User

logger = logging.getLogger("budgly")


class UserSignIn(BaseModel):
email: EmailStr
password: Annotated[str, Field(min_length=1)]


class FirebaseToken(BaseModel):
user_id: str = Field(..., alias="localId")
token: str = Field(..., alias="idToken")
refresh_token: str = Field(..., alias="refreshToken")
email: EmailStr
name: str = Field(..., alias="displayName")


class GoogleToken(BaseModel):
id_token: str
refresh_token: str


class GoogleAuthorizationUrl(BaseModel):
url: HttpUrl


class RefreshTokenPayload(BaseModel):
refresh_token: Annotated[str, Field(min_length=1)]


@lru_cache
Expand All @@ -23,3 +59,28 @@ def google_flow(redirect_uri: URL) -> Flow:
],
redirect_uri=redirect_uri,
)


def get_google_auth_flow(request: Request) -> Flow:
return google_flow(redirect_uri=request.url_for("auth_google"))


security = HTTPBearer()

TokenDep = Annotated[HTTPAuthorizationCredentials, Depends(security)]


def get_firebase_user(token: TokenDep) -> User:
app = firebase_app()
try:
claims = auth.verify_id_token(
token.credentials, app=app, check_revoked=True, clock_skew_seconds=10
)
return User(**claims)
except auth.ExpiredIdTokenError:
raise ExpiredToken()
except auth.InvalidIdTokenError:
raise InvalidToken()
except Exception as e:
logger.error(e)
raise ServerError(detail=str(e))
2 changes: 1 addition & 1 deletion app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ class Settings(BaseSettings):
@property
def SQLALCHEMY_DATABASE_URI(self) -> PostgresDsn:
return MultiHostUrl.build(
scheme="postgresql+psycopg",
scheme="postgresql+asyncpg",
username=self.POSTGRES_USER,
password=self.POSTGRES_PASSWORD,
host=self.POSTGRES_HOST,
Expand Down
26 changes: 11 additions & 15 deletions app/core/db.py
Original file line number Diff line number Diff line change
@@ -1,23 +1,19 @@
from datetime import datetime
from collections.abc import Generator

from sqlalchemy import Column, DateTime, func
from sqlmodel import Field, SQLModel, create_engine
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlmodel import Field, SQLModel
from sqlmodel.ext.asyncio.session import AsyncSession

from core.config import settings

engine = create_engine(str(settings.SQLALCHEMY_DATABASE_URI))
engine = create_async_engine(str(settings.SQLALCHEMY_DATABASE_URI), future=True)


async def get_session() -> Generator[AsyncSession, None, None]:
async_session = async_sessionmaker(engine, expire_on_commit=False)
async with async_session() as session:
yield session


class BaseTable(SQLModel):
id: int | None = Field(default=None, primary_key=True)

created_at: datetime | None = Field(
default=None,
sa_column=Column(
DateTime(timezone=True), server_default=func.now(), nullable=True
),
)
updated_at: datetime | None = Field(
default=None,
sa_column=Column(DateTime(timezone=True), onupdate=func.now(), nullable=True),
)
Loading

0 comments on commit aafc200

Please sign in to comment.