Skip to content

Commit 84e4609

Browse files
committed
Refactoring of backend: implementing a layered architecture
1 parent 63ebdaf commit 84e4609

17 files changed

+205
-128
lines changed

README.md

+6-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ General:
66
Backend:
77
- FastAPI as the Webserver
88
- PostgresQL + sqlalchemy on the Backend
9-
- Alembi for Database migrations
9+
1010
- Containerization with Docker and Uvicorn for serving
1111
- Ruff for style checks
1212
- Github Actions as CI/CD for automatic code checking, using Ruff and ESLint
@@ -17,6 +17,11 @@ Frontend:
1717
- Usage of ESLint for linting
1818
- Containerization with Docker and Docker compose
1919

20+
TODO:
21+
- Alembi for Database migrations
22+
- mypy for static type checking
23+
24+
2025
# Installation
2126

2227
First, we will need to install the dependencies and pre-commit hooks.

backend/app/app.py

+4-3
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1+
from .tasks.routers import router as task_router
2+
from .users.routers import router as user_router
13
from fastapi import FastAPI
24
from contextlib import asynccontextmanager
35

4-
from .routers import task, user
56
from .db import create_db_and_tables
67

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

1617

1718
app = FastAPI(lifespan=lifespan)
18-
app.include_router(task.router)
19-
app.include_router(user.router)
19+
app.include_router(task_router)
20+
app.include_router(user_router)
2021

2122
origins = [
2223
"*",

backend/app/auth.py

+4-3
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,11 @@
66

77
from fastapi import Depends, HTTPException, status
88
from fastapi.security import OAuth2PasswordBearer
9-
from sqlmodel import Session, select
9+
from sqlalchemy.orm import Session
10+
from sqlalchemy import select
1011

1112
from app.db import get_session
12-
from app.models.user import User, TokenData
13+
from app.users.models import User, TokenData
1314

1415
# TODO: Pass as configuration, hardcoded for now
1516
SECRET_KEY = "09d25e094faa6ca2556c818166b7a9563b93f7099f6f0f4caa6cf63b88e8d3e7"
@@ -54,7 +55,7 @@ async def get_current_user(
5455
raise credentials_exception
5556

5657
stmt = select(User).where(User.username == token_data.username)
57-
db_user = session.exec(stmt).first()
58+
db_user = session.exec(stmt).first()[0]
5859
if not db_user:
5960
raise credentials_exception
6061
return db_user

backend/app/models/__init__.py

Whitespace-only changes.

backend/app/routers/__init__.py

Whitespace-only changes.

backend/app/routers/task.py

-105
This file was deleted.

backend/app/settings.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44
class Settings(BaseSettings):
55
# TODO: Remove this hardcoded default from here
66
database_url: str = (
7-
"postgresql+psycopg2://myuser:mypassword@db:5432/mydatabase"
7+
# "postgresql+psycopg2://myuser:mypassword@db:5432/mydatabase"
8+
"postgresql+psycopg2://myuser:mypassword@localhost:5432/mydatabase"
89
)
910

1011

backend/app/models/task.py backend/app/tasks/models.py

-12
Original file line numberDiff line numberDiff line change
@@ -25,15 +25,3 @@ class Task(TaskBase, table=True):
2525
model_config = ConfigDict(validate_assignment=True)
2626

2727
id: int | None = Field(default=None, primary_key=True, index=True)
28-
29-
30-
class TaskCreate(TaskBase):
31-
pass
32-
33-
34-
class TaskUpdate(TaskBase):
35-
title: str | None = None
36-
description: str | None = None
37-
status: TaskStatus | None = Field(
38-
sa_column=Column(Enum(TaskStatus)), default=TaskStatus.created
39-
)

backend/app/tasks/repository.py

+43
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
from typing import List, Optional
2+
from sqlalchemy.orm import Session
3+
from sqlalchemy import select, update
4+
from dataclasses import dataclass
5+
from .models import Task, TaskStatus
6+
from .schema import TaskInput, TaskOutput
7+
8+
9+
@dataclass
10+
class TaskRepository:
11+
session: Session
12+
current_user_id: int
13+
14+
def create(self, data: TaskInput) -> TaskOutput:
15+
task = Task(**data.model_dump(exclude_none=True))
16+
self.session.add(task)
17+
self.session.commit()
18+
self.session.refresh(task)
19+
return TaskOutput(**dict(task))
20+
21+
def list(self, offset: int, limit: int) -> List[Optional[TaskOutput]]:
22+
stmt = (
23+
select(Task)
24+
.where(
25+
Task.status != TaskStatus.deleted,
26+
Task.user_id == self.current_user_id,
27+
)
28+
.offset(offset)
29+
.limit(limit)
30+
)
31+
tasks = self.session.execute(stmt).all()
32+
return [TaskOutput(**dict(task[0])) for task in tasks]
33+
34+
def get_by_id(self, id: int) -> Task:
35+
return self.session.get(Task, id)
36+
37+
def update(self, task: Task) -> TaskOutput:
38+
self.session.execute(
39+
update(Task).where(Task.id == task.id).values(**dict(task))
40+
)
41+
self.session.commit()
42+
self.session.refresh(task)
43+
return TaskOutput(**dict(task))

backend/app/tasks/routers.py

+63
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
from typing import Annotated
2+
from fastapi import Depends, Query, APIRouter
3+
from sqlalchemy.orm import Session
4+
from app.users.models import User
5+
from app.auth import get_current_active_user
6+
from app.db import get_session
7+
from .schema import TaskInput, TaskOutput
8+
from .service import TaskService
9+
10+
11+
router = APIRouter(prefix="/tasks")
12+
13+
14+
@router.get("/")
15+
def read_tasks(
16+
*,
17+
current_user: Annotated[User, Depends(get_current_active_user)],
18+
session: Session = Depends(get_session),
19+
offset: int = 0,
20+
limit: int = Query(default=100, le=100),
21+
):
22+
return TaskService(session, current_user.id).list(offset, limit)
23+
24+
25+
@router.post("/", response_model=TaskOutput)
26+
def create_task(
27+
*,
28+
current_user: Annotated[User, Depends(get_current_active_user)],
29+
session: Session = Depends(get_session),
30+
data: TaskInput,
31+
):
32+
return TaskService(session, current_user.id).create(data)
33+
34+
35+
@router.get("/{task_id}")
36+
def read_task(
37+
*,
38+
session: Session = Depends(get_session),
39+
current_user: Annotated[User, Depends(get_current_active_user)],
40+
task_id: int,
41+
):
42+
return TaskService(session, current_user.id).read(task_id)
43+
44+
45+
@router.patch("/{task_id}")
46+
def update_task(
47+
*,
48+
session: Session = Depends(get_session),
49+
current_user: Annotated[User, Depends(get_current_active_user)],
50+
task_id: int,
51+
task: TaskInput,
52+
):
53+
return TaskService(session, current_user.id).update(task_id, task)
54+
55+
56+
@router.delete("/{task_id}")
57+
def delete_task(
58+
*,
59+
current_user: Annotated[User, Depends(get_current_active_user)],
60+
session: Session = Depends(get_session),
61+
task_id: int,
62+
):
63+
return TaskService(session, current_user.id).delete(task_id)

backend/app/tasks/schema.py

+21
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import datetime
2+
from typing import Optional
3+
from pydantic import BaseModel, Field
4+
from .models import TaskStatus
5+
6+
7+
class TaskInput(BaseModel):
8+
title: str = Field(min_length=1, max_length=50)
9+
description: str | None = None
10+
user_id: int | None = None
11+
due_date: datetime.datetime | None = None
12+
status: TaskStatus = TaskStatus.created
13+
14+
15+
class TaskOutput(BaseModel):
16+
id: int
17+
title: str
18+
description: Optional[str] = ""
19+
user_id: int
20+
due_date: datetime.datetime | None = None
21+
status: TaskStatus

backend/app/tasks/service.py

+52
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
from typing import List
2+
from sqlalchemy.orm import Session
3+
from dataclasses import dataclass
4+
from fastapi import HTTPException
5+
from .repository import TaskRepository
6+
from .models import TaskStatus, Task
7+
from .schema import TaskInput, TaskOutput
8+
9+
10+
@dataclass
11+
class TaskService:
12+
session: Session
13+
current_user_id: int
14+
15+
def __post_init__(self):
16+
self.repository: TaskRepository = TaskRepository(
17+
self.session, self.current_user_id
18+
)
19+
20+
def get_or_not_found(self, task_id: int) -> Task:
21+
task = self.repository.get_by_id(task_id)
22+
if (not task) or (task.user_id != self.current_user_id):
23+
raise HTTPException(status_code=404, detail="Task not found")
24+
return task
25+
26+
def list(self, offset: int, limit: int) -> List[TaskOutput]:
27+
return self.repository.list(offset, limit)
28+
29+
def create(self, data: TaskInput) -> TaskOutput:
30+
data.user_id = self.current_user_id
31+
return self.repository.create(data)
32+
33+
def read(self, task_id: int) -> TaskOutput:
34+
task = self.get_or_not_found(task_id)
35+
return TaskOutput(**dict(task))
36+
37+
def update(self, task_id: int, data: TaskInput) -> TaskOutput:
38+
task = self.get_or_not_found(task_id)
39+
task.title = data.title
40+
task.description = data.description
41+
task.due_date = data.due_date
42+
43+
task.user_id = self.current_user_id
44+
self.repository.update(task)
45+
return TaskOutput(**dict(task))
46+
47+
def delete(self, task_id: int) -> TaskOutput:
48+
task = self.get_or_not_found(task_id)
49+
50+
task.status = TaskStatus.deleted
51+
self.repository.update(task)
52+
return task
File renamed without changes.

backend/app/users/repository.py

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
from dataclasses import dataclass
2+
from sqlalchemy.orm import Session
3+
4+
5+
@dataclass
6+
class UserRepository:
7+
session: Session

backend/app/routers/user.py backend/app/users/routers.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from fastapi.security import OAuth2PasswordRequestForm
88
from passlib.hash import pbkdf2_sha256
99

10-
from app.models.user import User, UserCreate
10+
from app.users.models import User, UserCreate
1111
from app.db import get_session
1212
from app.auth import (
1313
create_access_token,

0 commit comments

Comments
 (0)