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
26 changes: 26 additions & 0 deletions .github/workflows/backend-tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
name: Backend Tests

on:
push:
branches: [ main ]
pull_request:
branches: [ main ]

jobs:
backend-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install dependencies
run: |
cd backend
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Run tests
run: |
cd backend
pytest -q
156 changes: 156 additions & 0 deletions backend/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
# Backend (FastAPI)

This directory contains the backend application built with FastAPI.

## Overview

- **Framework**: FastAPI
- **Language**: Python (version 3.12 recommended)
- **Database**: PostgreSQL (version 17 recommended)
- **Authentication**: JWT (JSON Web Tokens)

## Project Structure

```
backend/
├── app/
│ ├── config/ # Configuration settings (from .env)
│ ├── db/ # Database models and sessions
│ ├── logs/ # Log files
│ ├── routes/ # API routes and endpoints
│ ├── schemas/ # Pydantic schemas (data validation)
│ ├── services/ # Business logic services
│ ├── utils/ # Utility functions and constants
│ └── main.py # FastAPI application instance and main router
├── alembic/ # Alembic migrations (if using Alembic)
├── tests/ # Unit and integration tests
├── .env.example # Example environment variables
├── .gitignore # Git ignore file
├── alembic.ini # Alembic configuration (if used)
├── Dockerfile # Dockerfile for containerization
├── pyproject.toml # pyproject.toml for Poetry
├── README.md # This file
└── requirements.txt # Project dependencies
```

## Getting Started

### Prerequisites

- Python (version 3.12 recommended)
- Pip (Python package installer)
- A running PostgreSQL instance (version 17 recommended)

### Installation & Setup

1. **Navigate to the `backend` directory:**
```bash
cd backend
```

2. **Create and activate a virtual environment:**
```bash
python -m venv venv
source venv/bin/activate # On Windows: venv\Scripts\activate
```

3. **Install dependencies:**
```bash
pip install -r requirements.txt
```

4. **Environment Variables:**
Create a `.env` file in the `backend` directory by copying `.env.example` (if one exists) or by creating it manually. This file will store sensitive configuration and should not be committed to version control if it contains real secrets.

Key environment variables:

- `DATABASE_URL`: The connection string for your PostgreSQL database (e.g., `postgresql://user:password@host:port/dbname`).
- `SECRET_KEY`: A strong, unique secret key used for signing JWTs and other security purposes. Generate one using `openssl rand -hex 32`.
- `ALGORITHM`: The algorithm used for JWTs (e.g., `HS256`).
- `ACCESS_TOKEN_EXPIRE_MINUTES`: Expiration time for access tokens.
- `CORS_ORIGINS`: Comma-separated list of allowed CORS origins (e.g., `http://localhost:5173,https://yourdomain.com`).

Example `.env`:
```env
DATABASE_URL="postgresql://postgres:changethis@localhost:5432/appdb"
SECRET_KEY="your_very_strong_secret_key_here"
ALGORITHM="HS256"
ACCESS_TOKEN_EXPIRE_MINUTES=30
CORS_ORIGINS="http://localhost:5173,http://127.0.0.1:5173"
```

5. **Database Migrations (with Alembic):**
This project uses Alembic to manage database schema migrations. Ensure your `alembic.ini` is configured and your `migrations/env.py` correctly points to your SQLAlchemy models' metadata.

Common commands (run from the `backend` directory):

- **Generate a new migration script (after model changes):**
```bash
alembic revision -m "your_descriptive_migration_message" --autogenerate
```
*(Always review autogenerated scripts carefully.)*
- **Apply all pending migrations to the database:**
```bash
alembic upgrade head
```
- **View migration history:**
```bash
alembic history
```

For a comprehensive guide on using Alembic, including setup, writing migrations, and best practices, please refer to the [Database Migrations with Alembic](../../docs/backend/alembic-migrations.md) documentation.

### Running the Development Server

## Key FastAPI Concepts

This project leverages several powerful features of FastAPI:

* **Pydantic Models:** Used for data validation, serialization, and settings management (see `app/schemas/`).
* **APIRouter:** For structuring your application into multiple, manageable modules (see `app/api/v1/endpoints/` and `app/api/api.py`).
* **Dependency Injection:** Extensively used for database sessions, authentication, and other shared logic (see `app/api/v1/deps.py`).
* **Automatic API Docs:** Interactive Swagger UI available at `/docs` and ReDoc at `/redoc` when the development server is running.

For a detailed guide on FastAPI usage within this project, including creating endpoints, working with Pydantic, and authentication, refer to the [FastAPI Guide for Backend Development](../../docs/backend/fastapi-guide.md).


Once dependencies are installed and the `.env` file is configured, you can run the FastAPI development server:

```bash
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
```

- `--reload`: Enables auto-reloading when code changes.
- The API will be accessible at `http://localhost:8000`.
- Interactive API documentation (Swagger UI) will be at `http://localhost:8000/docs`.
- Alternative API documentation (ReDoc) will be at `http://localhost:8000/redoc`.

## API Structure

- API endpoints are defined in `app/api/v1/endpoints/`.
- Pydantic schemas for request/response validation are in `app/schemas/`.
- Database models (SQLAlchemy) are in `app/db/models/`.
- Business logic and CRUD operations are typically in `app/crud/` or `app/services/`.

## Testing

*(Describe how to run tests, e.g., `pytest`)*

```bash
# Example: pytest
pytest
```

## Further Information

**Project-Specific Guides:**

- [FastAPI Guide for Backend Development](../../docs/backend/fastapi-guide.md)
- [Database Migrations with Alembic](../../docs/backend/alembic-migrations.md)

**Official Documentation:**

- [FastAPI](https://fastapi.tiangolo.com/)
- [Pydantic](https://docs.pydantic.dev/)
- [SQLAlchemy](https://www.sqlalchemy.org/)
- [Alembic](https://alembic.sqlalchemy.org/)
9 changes: 7 additions & 2 deletions backend/app/config/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ class Settings(BaseSettings):
APP_VERSION: str = "1.0.0"
APP_NAME: str = "FastAPI React Starter"
APP_DESCRIPTION: str = "FastAPI React Starter Template"
DATABASE_URL: str = "sqlite+aiosqlite:///./app.db"
ENVIRONMENT: str = "development"
DATABASE_URL: str = ""
TEST_DATABASE_URL: Optional[str] = "sqlite+aiosqlite:///./test_app.db"
CORS_ORIGINS: List[str] = ["http://localhost:5173", "http://localhost:3000"]
API_PREFIX: str = "/api"

Expand Down Expand Up @@ -61,5 +63,8 @@ def get_settings() -> Settings:
},
}

ENVIRONMENT = os.getenv("ENVIRONMENT", "development")
ENVIRONMENT = os.getenv("ENVIRONMENT", "development").lower()
# Ensure environment is one of the defined keys, default to development if not
if ENVIRONMENT not in LOGGING_CONFIG:
ENVIRONMENT = "development"
CURRENT_LOGGING_CONFIG = LOGGING_CONFIG[ENVIRONMENT]
8 changes: 5 additions & 3 deletions backend/app/db/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
from app.utils.logger import setup_logger
from app.config import get_settings
from urllib.parse import quote_plus
from typing import Optional

settings = get_settings()
logger = setup_logger(__name__)
Expand All @@ -13,15 +12,18 @@
def get_database_url() -> str:
"""
Constructs the database URL based on configuration.
Prioritizes TEST_DATABASE_URL if set in 'testing' environment.
Returns PostgreSQL URL if credentials are provided, otherwise falls back to SQLite.
"""
if settings.ENVIRONMENT == "testing" and settings.TEST_DATABASE_URL:
return settings.TEST_DATABASE_URL

if settings.DB_NAME:
# PostgreSQL connection
password = quote_plus(settings.DB_PASSWORD) if settings.DB_PASSWORD else ""
return f"postgresql+asyncpg://{settings.DB_USER}:{password}@{settings.DB_HOST}:{settings.DB_PORT}/{settings.DB_NAME}"

# SQLite connection (fallback)
return "sqlite+aiosqlite:///./app.db"
raise ValueError("No database URL found")


def create_engine_with_retry(database_url: str):
Expand Down
1 change: 1 addition & 0 deletions backend/app/schemas/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ class UserResponse(BaseModel):
email_verified: bool
role: str
created_at: datetime
updated_at: datetime

class Config:
from_attributes = True
9 changes: 9 additions & 0 deletions backend/pytest.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
[pytest]
python_files = test_*.py
python_classes = Test*
filterwarnings = ignore::DeprecationWarning
python_functions = test_*
asyncio_mode = auto
markers =
integration: Marks tests as integration tests
unit: Marks tests as unit tests
Binary file modified backend/requirements.txt
Binary file not shown.
104 changes: 104 additions & 0 deletions backend/tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
"""Pytest fixtures for FastAPI backend tests."""

from __future__ import annotations

import os
import sys
from typing import AsyncGenerator, Generator

import pytest
import pytest_asyncio
from httpx import AsyncClient
from sqlalchemy.ext.asyncio import (
AsyncSession,
async_sessionmaker,
create_async_engine,
)
from sqlalchemy.pool import NullPool
from sqlalchemy_utils import create_database, database_exists, drop_database

# ----------------------------------------------------------------------------
# Ensure test settings are loaded and project root is importable
# ----------------------------------------------------------------------------
os.environ.setdefault("ENVIRONMENT", "testing")
PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
sys.path.insert(0, PROJECT_ROOT)

from app.config import Settings, get_settings # noqa: E402 pylint: disable=wrong-import-position
from app.db import Base # noqa: E402 pylint: disable=wrong-import-position
from app.db.database import get_db # noqa: E402 pylint: disable=wrong-import-position
from app.main import app # noqa: E402 pylint: disable=wrong-import-position

# ---------------------------------------------------------------------------
# Session-scoped helpers
# ---------------------------------------------------------------------------


@pytest.fixture(scope="session")
def settings_instance() -> Settings:
"""Return fresh Settings for the test session."""
get_settings.cache_clear()
return get_settings()


@pytest.fixture(scope="session")
def effective_test_db_url(settings_instance: Settings) -> str:
"""Return DB URL for tests (env > default)."""
url = (settings_instance.TEST_DATABASE_URL or "").strip()
return url if url and url.lower() != "none" else settings_instance.DATABASE_URL


# ---------------------------------------------------------------------------
# Automatic DB lifecycle for the whole test session
# ---------------------------------------------------------------------------


@pytest_asyncio.fixture(scope="session", autouse=True)
async def _session_db_lifecycle(effective_test_db_url: str):
is_sqlite = effective_test_db_url.startswith("sqlite")

created_db = False
sync_url = None
if not is_sqlite:
sync_url = effective_test_db_url.replace("postgresql+asyncpg", "postgresql")
if not database_exists(sync_url):
create_database(sync_url)
created_db = True

engine = create_async_engine(effective_test_db_url, poolclass=NullPool)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)

yield # tests run here

async with engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
await engine.dispose()

if created_db and sync_url:
drop_database(sync_url)


# ---------------------------------------------------------------------------
# Per-test fixtures
# ---------------------------------------------------------------------------


@pytest_asyncio.fixture()
async def db_session(effective_test_db_url: str) -> AsyncGenerator[AsyncSession, None]:
engine = create_async_engine(effective_test_db_url, poolclass=NullPool)
session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async with session_factory() as session:
yield session
await engine.dispose()


@pytest_asyncio.fixture()
async def test_client(db_session: AsyncSession) -> AsyncGenerator[AsyncClient, None]:
def _override_get_db() -> Generator[AsyncSession, None, None]:
yield db_session

app.dependency_overrides[get_db] = _override_get_db
async with AsyncClient(app=app, base_url="http://test") as client:
yield client
app.dependency_overrides.pop(get_db, None)
Loading