Skip to content
Closed
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
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Copy this file to .env and fill in real values.
# Never commit .env to version control.
POSTGRES_DB=app
POSTGRES_USER=user
POSTGRES_PASSWORD=CHANGE_ME
DATABASE_URL=postgresql://user:CHANGE_ME@db/app
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,9 @@

# macOS
.DS_Store

# Environment / secrets – never commit real credentials
.env

# SQLite test database artefact
*.db
4 changes: 3 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,7 @@ RUN apt-get update && apt-get install -y libpq-dev
COPY . /app
WORKDIR /app
RUN pip install -r requirements.txt
RUN useradd -m appuser
USER appuser
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--log-level", "trace"]
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--log-level", "info"]
50 changes: 35 additions & 15 deletions conftest.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,34 @@
import pytest
from sqlalchemy import create_engine, inspect, text
from sqlalchemy.orm import sessionmaker
from fastapi.testclient import TestClient
from main import create_app
from dependencies import get_db, database_url
from models import Base
import os

# Set a default DATABASE_URL for tests when one is not already provided.
# This allows the test suite to run without a live PostgreSQL instance
# by falling back to an in-process SQLite database.
if not os.getenv("DATABASE_URL"):
os.environ["DATABASE_URL"] = "sqlite:///./test.db"

import pytest # noqa: E402
from sqlalchemy import create_engine, inspect # noqa: E402, F401
from sqlalchemy.orm import sessionmaker # noqa: E402
from fastapi.testclient import TestClient # noqa: E402
from main import create_app # noqa: E402
from dependencies import get_db # noqa: E402
from models import Base # noqa: E402

_DATABASE_URL = os.environ["DATABASE_URL"]


@pytest.fixture(scope="session")
def test_engine():
"""Create test database engine"""
test_engine = create_engine(database_url)
Base.metadata.create_all(bind=test_engine)
yield test_engine
Base.metadata.drop_all(bind=test_engine)
engine = create_engine(
_DATABASE_URL,
connect_args={"check_same_thread": False}
if _DATABASE_URL.startswith("sqlite")
else {},
)
Base.metadata.create_all(bind=engine)
yield engine
Base.metadata.drop_all(bind=engine)


@pytest.fixture(scope="function")
Expand All @@ -37,14 +53,18 @@ def test_app(test_engine):
"""Create test FastAPI application with test database"""

def override_get_db():
TestingSessionLocal = sessionmaker(
autocommit=False, autoflush=False, bind=test_engine
)
db = TestingSessionLocal()
connection = test_engine.connect()
transaction = connection.begin()
db = sessionmaker(
autocommit=False, autoflush=False, bind=connection,
join_transaction_mode="create_savepoint"
)()
try:
yield db
finally:
db.close()
transaction.rollback()
connection.close()

app = create_app()
app.dependency_overrides[get_db] = override_get_db
Expand Down
5 changes: 4 additions & 1 deletion dependencies.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import logging
from typing import Generator
import os
from dotenv import load_dotenv
Expand All @@ -6,6 +7,8 @@
from sqlalchemy.exc import SQLAlchemyError
from models import Base

logger = logging.getLogger(__name__)

load_dotenv()
database_url = os.getenv("DATABASE_URL")
if not database_url:
Expand All @@ -23,7 +26,7 @@ def init_db():
try:
Base.metadata.create_all(bind=engine)
except SQLAlchemyError as e:
print(f"Error initializing the database: {e}")
logger.error("Error initializing the database: %s", e)
raise


Expand Down
2 changes: 1 addition & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ services:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
ports:
- "5432:5432"
- "127.0.0.1:5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
- ./db.sql:/docker-entrypoint-initdb.d/db.sql
Expand Down
8 changes: 4 additions & 4 deletions main.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
from fastapi import FastAPI, Depends
from fastapi import FastAPI
from routers import router
from dependencies import init_db, get_db
from dependencies import init_db


def create_app() -> FastAPI:
"""Create and configure the FastAPI application"""
app = FastAPI()
app = FastAPI(docs_url=None, redoc_url=None)

# Initialize database
init_db()

# Include routers
app.include_router(router, prefix="/api", dependencies=[Depends(get_db)])
app.include_router(router, prefix="/api")

return app

Expand Down
9 changes: 7 additions & 2 deletions models.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import logging
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from sqlalchemy import String
from pydantic import BaseModel, ConfigDict

logger = logging.getLogger(__name__)


# SQLAlchemy models
class Base(DeclarativeBase):
"""Base class for all database models"""

pass


class Book(Base):
"""Book model"""
Expand All @@ -18,13 +19,16 @@ class Book(Base):
id: Mapped[int] = mapped_column(primary_key=True, index=True)
title: Mapped[str] = mapped_column(String(255), index=True)
author: Mapped[str] = mapped_column(String(255))
isbn: Mapped[str] = mapped_column(String(17))


# Pydantic models
class BookIn(BaseModel):
"""Pydantic model for book input"""

title: str
author: str
isbn: str


class BookOut(BaseModel):
Expand All @@ -33,5 +37,6 @@ class BookOut(BaseModel):
id: int
title: str
author: str
isbn: str

model_config = ConfigDict(from_attributes=True)
13 changes: 7 additions & 6 deletions repositories.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,38 +3,39 @@


# Create a new book
def create_book(db: Session, book: models.BookIn):
db_book = models.Book(title=book.title, author=book.author)
def create_book(db: Session, book: models.BookIn) -> models.Book:
db_book = models.Book(title=book.title, author=book.author, isbn=book.isbn)
db.add(db_book)
db.commit()
db.refresh(db_book)
return db_book


# Get all books
def get_books(db: Session, skip: int = 0, limit: int = 10):
def get_books(db: Session, skip: int = 0, limit: int = 10) -> list[models.Book]:
return db.query(models.Book).offset(skip).limit(limit).all()


# Get a book by ID
def get_book(db: Session, book_id: int):
def get_book(db: Session, book_id: int) -> models.Book | None:
return db.query(models.Book).filter(models.Book.id == book_id).first()


# Update a book
def update_book(db: Session, book_id: int, book: models.BookIn):
def update_book(db: Session, book_id: int, book: models.BookIn) -> models.Book | None:
db_book = db.query(models.Book).filter(models.Book.id == book_id).first()
if db_book:
db_book.title = book.title
db_book.author = book.author
db_book.isbn = book.isbn
db.commit()
db.refresh(db_book)
return db_book
return None


# Delete a book
def delete_book(db: Session, book_id: int):
def delete_book(db: Session, book_id: int) -> models.Book | None:
db_book = db.query(models.Book).filter(models.Book.id == book_id).first()
if db_book:
db.delete(db_book)
Expand Down
14 changes: 7 additions & 7 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
fastapi[standard]>=0.135.3
sqlalchemy>=2.0.49
psycopg2-binary>=2.9.0
pydantic>=2.12.5
pytest>=9.0.3
python-dotenv>=1.2.2
uvicorn[standard]>=0.44.0
fastapi[standard]==0.136.1
sqlalchemy==2.0.49
psycopg2-binary==2.9.12
pydantic==2.13.4
pytest==9.0.3
python-dotenv==1.2.2
uvicorn[standard]==0.46.0
25 changes: 20 additions & 5 deletions routers.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import logging
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from typing import List
from sqlalchemy.exc import IntegrityError
import models
import repositories
from dependencies import get_db

logger = logging.getLogger(__name__)

# Create router with prefix
router = APIRouter()

Expand All @@ -15,20 +18,32 @@
def create_book(book: models.BookIn, db: Session = Depends(get_db)):
try:
return repositories.create_book(db=db, book=book)
except IntegrityError as e:
logger.error("IntegrityError creating book: %s", e)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="An internal error occurred.",
) from e
except Exception as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
logger.error("Unexpected error creating book: %s", e)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="An internal error occurred.",
) from e


@router.get(
"/books/", response_model=List[models.BookOut], status_code=status.HTTP_200_OK
"/books/", response_model=list[models.BookOut], status_code=status.HTTP_200_OK
)
def get_books(skip: int = 0, limit: int = 10, db: Session = Depends(get_db)):
try:
return repositories.get_books(db=db, skip=skip, limit=limit)
except Exception as e:
logger.error("Unexpected error retrieving books: %s", e)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e)
)
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="An internal error occurred.",
) from e


@router.get(
Expand Down
Loading
Loading