Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ POSTGRES_PASSWORD = <DB_PASSWORD>
POSTGRES_DB = <DB_NAME>
DATABASE_URL = <DB_URL>

REDIS_URL = <REDIS_URL>

DEBUG = <True/False>

SLOW_REQUEST_THRESHOLD = <seconds: float>
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
DEBUG: ${{ secrets.DEBUG }}
SLOW_REQUEST_THRESHOLD: ${{ secrets.SLOW_REQUEST_THRESHOLD }}
REDIS_URL: ${{ secrets.REDIS_URL }}

jobs:
test:
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/docker.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
DEBUG: ${{ secrets.DEBUG }}
SLOW_REQUEST_THRESHOLD: ${{ secrets.SLOW_REQUEST_THRESHOLD }}
REDIS_URL: ${{ secrets.REDIS_URL }}

jobs:

Expand Down
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@ __pycache__
*.db
.env
check-list-docker.md
ideas.md
ideas.md
my_notes.md
18 changes: 12 additions & 6 deletions app/api/v1/endpoints/habits.py
Original file line number Diff line number Diff line change
@@ -1,29 +1,35 @@
from fastapi import APIRouter, Depends, status
from fastapi import APIRouter, Depends, status, Request
from typing import List
from app.dependencies import get_habit_service
from app.services.habit_service import HabitService
from app.schemas.habit import HabitCreate, HabitUpdate, HabitResponse
from app.middlewares.rate_limit.limiter import limiter

router = APIRouter()

@router.get('/habits', response_model = List[HabitResponse])
async def get_all(service: HabitService = Depends(get_habit_service)):
@limiter.limit('5/minute')
async def get_all(request: Request, service: HabitService = Depends(get_habit_service)):
return await service.get_all()

@router.post('/habit/create', response_model = HabitResponse, status_code = status.HTTP_201_CREATED)
async def create(data: HabitCreate, service: HabitService = Depends(get_habit_service)):
@limiter.limit('5/minute')
async def create(request: Request, data: HabitCreate, service: HabitService = Depends(get_habit_service)):
return await service.create(data)

@router.get('/habit/{habit_id}', response_model = HabitResponse)
async def get_by_id(habit_id: int, service: HabitService = Depends(get_habit_service)):
@limiter.limit('5/minute')
async def get_by_id(request: Request, habit_id: int, service: HabitService = Depends(get_habit_service)):
return await service.get_by_id(habit_id)

@router.delete('/habit/{habit_id}', status_code = status.HTTP_204_NO_CONTENT)
async def delete_by_id(habit_id: int, service: HabitService = Depends(get_habit_service)):
@limiter.limit('5/minute')
async def delete_by_id(request: Request, habit_id: int, service: HabitService = Depends(get_habit_service)):
await service.delete(habit_id)
return None

@router.put('/habit/{habit_id}', response_model = HabitResponse)
async def update(habit_id: int, data: HabitUpdate, service: HabitService = Depends(get_habit_service)):
@limiter.limit('5/minute')
async def update(request: Request, habit_id: int, data: HabitUpdate, service: HabitService = Depends(get_habit_service)):
return await service.update(habit_id, data)

9 changes: 6 additions & 3 deletions app/api/v1/endpoints/log.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
from fastapi import APIRouter, Depends, status
from fastapi import APIRouter, Depends, status, Request
from app.dependencies import get_log_service
from app.services.log_service import LogService
from app.schemas.log import LogCreate
from app.schemas.log import LogResponse
from app.middlewares.rate_limit.limiter import limiter

router = APIRouter()

@router.post('/log/{habit_id}', response_model = LogResponse, status_code = status.HTTP_201_CREATED)
async def create(habit_id: int, data: LogCreate, service: LogService = Depends(get_log_service)):
@limiter.limit('1/day')
async def create(request: Request, habit_id: int, data: LogCreate, service: LogService = Depends(get_log_service)):
return await service.create(habit_id, data)

@router.delete('/log/{log_id}', status_code = status.HTTP_204_NO_CONTENT)
async def delete(log_id: int, service: LogService = Depends(get_log_service)):
@limiter.limit('5/minute')
async def delete(request: Request, log_id: int, service: LogService = Depends(get_log_service)):
await service.delete(log_id)
return None
6 changes: 4 additions & 2 deletions app/api/v1/endpoints/stats.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, Request
from app.dependencies import get_stats_service
from app.services.stats_service import StatsService
from app.schemas.stats import StatsResponse
from app.middlewares.rate_limit.limiter import limiter

router = APIRouter()

@router.get('/stats/{habit_id}', response_model = StatsResponse)
async def get(habit_id: int, service: StatsService = Depends(get_stats_service)):
@limiter.limit('5/minute')
async def get(request: Request, habit_id: int, service: StatsService = Depends(get_stats_service)):
return await service.get_stats(habit_id)
1 change: 1 addition & 0 deletions app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ class Settings(BaseSettings):
POSTGRES_PASSWORD: str
POSTGRES_DB: str
DATABASE_URL: str
REDIS_URL: str
SLOW_REQUEST_THRESHOLD: float
DEBUG: bool = True

Expand Down
Empty file added app/infrastructure/__init__.py
Empty file.
Empty file.
24 changes: 24 additions & 0 deletions app/infrastructure/redis/redis.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Redis client for caching and storing temporary data. Currently not used in the project and reserved for future innovations.

import redis.asyncio as redis
from app.core.config import settings

class RedisClient:
_client = None

@classmethod
async def get_client(cls) -> redis.Redis:
if cls._client is None:
cls._client = redis.from_url(
settings.REDIS_URL,
decode_responses = True,
max_connections = 10
)
return cls._client

@classmethod
async def close(cls):
if cls._client:
await cls._client.aclose()
cls._client = None

7 changes: 6 additions & 1 deletion app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@

from fastapi import FastAPI, Request, status
from fastapi.responses import JSONResponse
from slowapi.errors import RateLimitExceeded
from contextlib import asynccontextmanager
from app.core.database import engine, Base
from app.core.middleware import LoggingMiddleware
from app.middlewares.rate_limit.limiter import limiter, rate_limit_exceed_handler
from app.middlewares.logging.logging_middleware import LoggingMiddleware
from app.api import router
from app.core.logger import logger

Expand All @@ -17,6 +19,9 @@ async def lifespan(app: FastAPI):

app = FastAPI(lifespan = lifespan)

app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, rate_limit_exceed_handler)

@app.exception_handler(Exception)
async def global_exeption_handler(request: Request, exc: Exception):
logger.error(
Expand Down
Empty file added app/middlewares/__init__.py
Empty file.
Empty file.
File renamed without changes.
Empty file.
20 changes: 20 additions & 0 deletions app/middlewares/rate_limit/limiter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
from slowapi import Limiter
from slowapi.util import get_remote_address
from slowapi.errors import RateLimitExceeded
from fastapi import Request, status
import redis.asyncio as redis
from app.core.config import settings
from fastapi.responses import JSONResponse

limiter = Limiter(
key_func = get_remote_address,
storage_uri = settings.REDIS_URL,
default_limits = ['20/minute']
)

def rate_limit_exceed_handler(request: Request, exc: RateLimitExceeded):
return JSONResponse(
status_code = status.HTTP_429_TOO_MANY_REQUESTS,
content = {'detail' : 'Too many requests.'}
)

12 changes: 11 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,23 @@ services:
POSTGRES_DB: ${POSTGRES_DB}
ports:
- "5432:5432"
restart: unless-stopped
volumes:
- postgres_data:/var/lib/postgresql/data


redis:
image: redis:8.8-alpine
container_name: trackit-redis
ports:
- "6379:6379"
restart: unless-stopped

api:
build: .
container_name: trackit-api
depends_on:
- db
- redis
ports:
- "8000:8000"
environment:
Expand Down
5 changes: 5 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@ asgi-lifespan==2.1.0
asyncpg==0.31.0
certifi==2026.6.17
click==8.4.2
Deprecated==1.3.1
fastapi==0.138.1
greenlet==3.5.3
h11==0.16.0
httpcore==1.0.9
httpx==0.28.1
idna==3.18
iniconfig==2.3.0
limits==5.8.0
loguru==0.7.3
packaging==26.2
pluggy==1.6.0
Expand All @@ -23,9 +25,12 @@ Pygments==2.20.0
pytest==9.1.1
pytest-asyncio==1.4.0
python-dotenv==1.2.2
redis==8.0.1
slowapi==0.1.10
sniffio==1.3.1
SQLAlchemy==2.0.51
starlette==1.3.1
typing-inspection==0.4.2
typing_extensions==4.15.0
uvicorn==0.49.0
wrapt==2.2.2
19 changes: 19 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,27 @@
from httpx import AsyncClient, ASGITransport
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from app.core.database import Base, get_db

import app.middlewares.rate_limit.limiter as rate_limit_module
from slowapi import Limiter
from slowapi.util import get_remote_address

def mock_limit(self, limit_str: str):
def decorator(func):
return func
return decorator

original_limiter_class = Limiter
rate_limit_module.limiter = Limiter(
key_func=get_remote_address,
default_limits=["100/minute"]
)
rate_limit_module.limiter.limit = mock_limit.__get__(rate_limit_module.limiter, Limiter)

from app.main import app

app.state.limiter = rate_limit_module.limiter

TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:"
engine = create_async_engine(TEST_DATABASE_URL, echo=False)
TestingSessionLocal = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
Expand Down