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
5 changes: 4 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
FROM python:3.11
RUN apt-get update && apt-get install -y libpq-dev
RUN pip install --upgrade pip wheel
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"]
42 changes: 29 additions & 13 deletions conftest.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,19 @@
import pytest
from sqlalchemy import create_engine, inspect, text
from sqlalchemy import create_engine, inspect
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


@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)
Base.metadata.create_all(bind=engine)
yield engine
Base.metadata.drop_all(bind=engine)


@pytest.fixture(scope="function")
Expand All @@ -21,8 +22,10 @@ def test_db(test_engine):
connection = test_engine.connect()
transaction = connection.begin()
db = sessionmaker(
autocommit=False, autoflush=False, bind=connection,
join_transaction_mode="create_savepoint"
autocommit=False,
autoflush=False,
bind=connection,
join_transaction_mode="create_savepoint",
)()
try:
yield db
Expand All @@ -34,21 +37,34 @@ def test_db(test_engine):

@pytest.fixture
def test_app(test_engine):
"""Create test FastAPI application with test database"""
"""Create test FastAPI application with test database.

Uses a single connection/transaction per test so that data written in one
request is visible to subsequent requests within the same test, while still
being rolled back at the end of the test.
"""
connection = test_engine.connect()
transaction = connection.begin()
testing_session_local = sessionmaker(
autocommit=False,
autoflush=False,
bind=connection,
join_transaction_mode="create_savepoint",
)

def override_get_db():
TestingSessionLocal = sessionmaker(
autocommit=False, autoflush=False, bind=test_engine
)
db = TestingSessionLocal()
db = testing_session_local()
try:
yield db
finally:
db.close()

app = create_app()
app.dependency_overrides[get_db] = override_get_db
return app
yield app

transaction.rollback()
connection.close()


@pytest.fixture
Expand Down
5 changes: 3 additions & 2 deletions db.sql
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ DROP TABLE IF EXISTS books;

CREATE TABLE books (
id SERIAL PRIMARY KEY,
title VARCHAR(50) UNIQUE NOT NULL,
author VARCHAR(100) UNIQUE NOT NULL
title VARCHAR(255) NOT NULL,
author VARCHAR(255) NOT NULL,
isbn VARCHAR(17) UNIQUE NOT NULL
);
5 changes: 3 additions & 2 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,9 @@ services:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
ports:
- "5432:5432"
# Port 5432 is intentionally NOT exposed to the host to prevent
# unauthenticated external access; the web service reaches it via
# the internal Docker network.
volumes:
- postgres_data:/var/lib/postgresql/data
- ./db.sql:/docker-entrypoint-initdb.d/db.sql
Expand Down
4 changes: 4 additions & 0 deletions models.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,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), unique=True)


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

title: str
author: str
isbn: str


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

model_config = ConfigDict(from_attributes=True)
8 changes: 7 additions & 1 deletion repositories.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@

# Create a new book
def create_book(db: Session, book: models.BookIn):
db_book = models.Book(title=book.title, author=book.author)
"""Create and persist a new book record."""
db_book = models.Book(title=book.title, author=book.author, isbn=book.isbn)
db.add(db_book)
db.commit()
db.refresh(db_book)
Expand All @@ -13,20 +14,24 @@ def create_book(db: Session, book: models.BookIn):

# Get all books
def get_books(db: Session, skip: int = 0, limit: int = 10):
"""Return a paginated list of books."""
return db.query(models.Book).offset(skip).limit(limit).all()


# Get a book by ID
def get_book(db: Session, book_id: int):
"""Return a single book by its ID, or None if not found."""
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):
"""Update an existing book's fields; returns None if not found."""
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
Expand All @@ -35,6 +40,7 @@ def update_book(db: Session, book_id: int, book: models.BookIn):

# Delete a book
def delete_book(db: Session, book_id: int):
"""Delete a book by ID; returns the deleted object or None if not found."""
db_book = db.query(models.Book).filter(models.Book.id == book_id).first()
if db_book:
db.delete(db_book)
Expand Down
21 changes: 15 additions & 6 deletions routers.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
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
Expand All @@ -13,28 +13,35 @@
"/books/", response_model=models.BookOut, status_code=status.HTTP_201_CREATED
)
def create_book(book: models.BookIn, db: Session = Depends(get_db)):
"""Create a new book."""
try:
return repositories.create_book(db=db, book=book)
except Exception as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
except IntegrityError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="A book with that ISBN already exists or required fields are missing.",
) from None


@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)):
"""Return a paginated list of books."""
try:
return repositories.get_books(db=db, skip=skip, limit=limit)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e)
)
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="An error occurred while retrieving books.",
) from e


@router.get(
"/books/{book_id}", response_model=models.BookOut, status_code=status.HTTP_200_OK
)
def get_book(book_id: int, db: Session = Depends(get_db)):
"""Return a single book by ID."""
db_book = repositories.get_book(db=db, book_id=book_id)
if db_book is None:
raise HTTPException(
Expand All @@ -47,6 +54,7 @@ def get_book(book_id: int, db: Session = Depends(get_db)):
"/books/{book_id}", response_model=models.BookOut, status_code=status.HTTP_200_OK
)
def update_book(book_id: int, book: models.BookIn, db: Session = Depends(get_db)):
"""Update an existing book by ID."""
db_book = repositories.update_book(db=db, book_id=book_id, book=book)
if db_book is None:
raise HTTPException(
Expand All @@ -59,6 +67,7 @@ def update_book(book_id: int, book: models.BookIn, db: Session = Depends(get_db)
"/books/{book_id}", response_model=models.BookOut, status_code=status.HTTP_200_OK
)
def delete_book(book_id: int, db: Session = Depends(get_db)):
"""Delete a book by ID."""
db_book = repositories.delete_book(db=db, book_id=book_id)
if db_book is None:
raise HTTPException(
Expand Down
Loading
Loading