Skip to content

Commit

Permalink
Refactoring of backend: implementing a layered architecture
Browse files Browse the repository at this point in the history
  • Loading branch information
diofeher committed Nov 5, 2024
1 parent 63ebdaf commit 84e4609
Show file tree
Hide file tree
Showing 17 changed files with 205 additions and 128 deletions.
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ General:
Backend:
- FastAPI as the Webserver
- PostgresQL + sqlalchemy on the Backend
- Alembi for Database migrations

- Containerization with Docker and Uvicorn for serving
- Ruff for style checks
- Github Actions as CI/CD for automatic code checking, using Ruff and ESLint
Expand All @@ -17,6 +17,11 @@ Frontend:
- Usage of ESLint for linting
- Containerization with Docker and Docker compose

TODO:
- Alembi for Database migrations
- mypy for static type checking


# Installation

First, we will need to install the dependencies and pre-commit hooks.
Expand Down
7 changes: 4 additions & 3 deletions backend/app/app.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
from .tasks.routers import router as task_router
from .users.routers import router as user_router
from fastapi import FastAPI
from contextlib import asynccontextmanager

from .routers import task, user
from .db import create_db_and_tables

from fastapi.middleware.cors import CORSMiddleware
Expand All @@ -15,8 +16,8 @@ async def lifespan(app: FastAPI):


app = FastAPI(lifespan=lifespan)
app.include_router(task.router)
app.include_router(user.router)
app.include_router(task_router)
app.include_router(user_router)

origins = [
"*",
Expand Down
7 changes: 4 additions & 3 deletions backend/app/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@

from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from sqlmodel import Session, select
from sqlalchemy.orm import Session
from sqlalchemy import select

from app.db import get_session
from app.models.user import User, TokenData
from app.users.models import User, TokenData

# TODO: Pass as configuration, hardcoded for now
SECRET_KEY = "09d25e094faa6ca2556c818166b7a9563b93f7099f6f0f4caa6cf63b88e8d3e7"
Expand Down Expand Up @@ -54,7 +55,7 @@ async def get_current_user(
raise credentials_exception

stmt = select(User).where(User.username == token_data.username)
db_user = session.exec(stmt).first()
db_user = session.exec(stmt).first()[0]
if not db_user:
raise credentials_exception
return db_user
Expand Down
Empty file removed backend/app/models/__init__.py
Empty file.
Empty file removed backend/app/routers/__init__.py
Empty file.
105 changes: 0 additions & 105 deletions backend/app/routers/task.py

This file was deleted.

3 changes: 2 additions & 1 deletion backend/app/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
class Settings(BaseSettings):
# TODO: Remove this hardcoded default from here
database_url: str = (
"postgresql+psycopg2://myuser:mypassword@db:5432/mydatabase"
# "postgresql+psycopg2://myuser:mypassword@db:5432/mydatabase"
"postgresql+psycopg2://myuser:mypassword@localhost:5432/mydatabase"
)


Expand Down
12 changes: 0 additions & 12 deletions backend/app/models/task.py → backend/app/tasks/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,3 @@ class Task(TaskBase, table=True):
model_config = ConfigDict(validate_assignment=True)

id: int | None = Field(default=None, primary_key=True, index=True)


class TaskCreate(TaskBase):
pass


class TaskUpdate(TaskBase):
title: str | None = None
description: str | None = None
status: TaskStatus | None = Field(
sa_column=Column(Enum(TaskStatus)), default=TaskStatus.created
)
43 changes: 43 additions & 0 deletions backend/app/tasks/repository.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
from typing import List, Optional
from sqlalchemy.orm import Session
from sqlalchemy import select, update
from dataclasses import dataclass
from .models import Task, TaskStatus
from .schema import TaskInput, TaskOutput


@dataclass
class TaskRepository:
session: Session
current_user_id: int

def create(self, data: TaskInput) -> TaskOutput:
task = Task(**data.model_dump(exclude_none=True))
self.session.add(task)
self.session.commit()
self.session.refresh(task)
return TaskOutput(**dict(task))

def list(self, offset: int, limit: int) -> List[Optional[TaskOutput]]:
stmt = (
select(Task)
.where(
Task.status != TaskStatus.deleted,
Task.user_id == self.current_user_id,
)
.offset(offset)
.limit(limit)
)
tasks = self.session.execute(stmt).all()
return [TaskOutput(**dict(task[0])) for task in tasks]

def get_by_id(self, id: int) -> Task:
return self.session.get(Task, id)

def update(self, task: Task) -> TaskOutput:
self.session.execute(
update(Task).where(Task.id == task.id).values(**dict(task))
)
self.session.commit()
self.session.refresh(task)
return TaskOutput(**dict(task))
63 changes: 63 additions & 0 deletions backend/app/tasks/routers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
from typing import Annotated
from fastapi import Depends, Query, APIRouter
from sqlalchemy.orm import Session
from app.users.models import User
from app.auth import get_current_active_user
from app.db import get_session
from .schema import TaskInput, TaskOutput
from .service import TaskService


router = APIRouter(prefix="/tasks")


@router.get("/")
def read_tasks(
*,
current_user: Annotated[User, Depends(get_current_active_user)],
session: Session = Depends(get_session),
offset: int = 0,
limit: int = Query(default=100, le=100),
):
return TaskService(session, current_user.id).list(offset, limit)


@router.post("/", response_model=TaskOutput)
def create_task(
*,
current_user: Annotated[User, Depends(get_current_active_user)],
session: Session = Depends(get_session),
data: TaskInput,
):
return TaskService(session, current_user.id).create(data)


@router.get("/{task_id}")
def read_task(
*,
session: Session = Depends(get_session),
current_user: Annotated[User, Depends(get_current_active_user)],
task_id: int,
):
return TaskService(session, current_user.id).read(task_id)


@router.patch("/{task_id}")
def update_task(
*,
session: Session = Depends(get_session),
current_user: Annotated[User, Depends(get_current_active_user)],
task_id: int,
task: TaskInput,
):
return TaskService(session, current_user.id).update(task_id, task)


@router.delete("/{task_id}")
def delete_task(
*,
current_user: Annotated[User, Depends(get_current_active_user)],
session: Session = Depends(get_session),
task_id: int,
):
return TaskService(session, current_user.id).delete(task_id)
21 changes: 21 additions & 0 deletions backend/app/tasks/schema.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import datetime
from typing import Optional
from pydantic import BaseModel, Field
from .models import TaskStatus


class TaskInput(BaseModel):
title: str = Field(min_length=1, max_length=50)
description: str | None = None
user_id: int | None = None
due_date: datetime.datetime | None = None
status: TaskStatus = TaskStatus.created


class TaskOutput(BaseModel):
id: int
title: str
description: Optional[str] = ""
user_id: int
due_date: datetime.datetime | None = None
status: TaskStatus
52 changes: 52 additions & 0 deletions backend/app/tasks/service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
from typing import List
from sqlalchemy.orm import Session
from dataclasses import dataclass
from fastapi import HTTPException
from .repository import TaskRepository
from .models import TaskStatus, Task
from .schema import TaskInput, TaskOutput


@dataclass
class TaskService:
session: Session
current_user_id: int

def __post_init__(self):
self.repository: TaskRepository = TaskRepository(
self.session, self.current_user_id
)

def get_or_not_found(self, task_id: int) -> Task:
task = self.repository.get_by_id(task_id)
if (not task) or (task.user_id != self.current_user_id):
raise HTTPException(status_code=404, detail="Task not found")
return task

def list(self, offset: int, limit: int) -> List[TaskOutput]:
return self.repository.list(offset, limit)

def create(self, data: TaskInput) -> TaskOutput:
data.user_id = self.current_user_id
return self.repository.create(data)

def read(self, task_id: int) -> TaskOutput:
task = self.get_or_not_found(task_id)
return TaskOutput(**dict(task))

def update(self, task_id: int, data: TaskInput) -> TaskOutput:
task = self.get_or_not_found(task_id)
task.title = data.title
task.description = data.description
task.due_date = data.due_date

task.user_id = self.current_user_id
self.repository.update(task)
return TaskOutput(**dict(task))

def delete(self, task_id: int) -> TaskOutput:
task = self.get_or_not_found(task_id)

task.status = TaskStatus.deleted
self.repository.update(task)
return task
File renamed without changes.
7 changes: 7 additions & 0 deletions backend/app/users/repository.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
from dataclasses import dataclass
from sqlalchemy.orm import Session


@dataclass
class UserRepository:
session: Session
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from fastapi.security import OAuth2PasswordRequestForm
from passlib.hash import pbkdf2_sha256

from app.models.user import User, UserCreate
from app.users.models import User, UserCreate
from app.db import get_session
from app.auth import (
create_access_token,
Expand Down
Loading

0 comments on commit 84e4609

Please sign in to comment.