Skip to content
Open
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
153 changes: 153 additions & 0 deletions PLANNING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
# Todo Application – Architecture Plan

## Technology Stack

| Layer | Technology |
|----------------|----------------------------------------------|
| Web framework | FastAPI 0.110+ |
| ORM | SQLAlchemy 2.0+ |
| Validation | Pydantic v2 |
| Database | SQLite in-memory (`sqlite://`) |
| ASGI server | uvicorn |
| Testing | pytest + httpx + fastapi.testclient |

> **Note:** The SQLite in-memory database is ephemeral — all data is lost
> when the process restarts.

## Project Structure

```
.
├── app/
│ ├── __init__.py
│ ├── main.py # FastAPI application factory & lifespan
│ ├── database.py # Engine, SessionLocal, Base, get_db
│ ├── models.py # SQLAlchemy ORM models
│ ├── schemas.py # Pydantic request/response schemas
│ └── routers/
│ ├── __init__.py
│ └── todos.py # /todos CRUD endpoints
├── tests/
│ ├── __init__.py
│ ├── test_database.py
│ ├── test_models.py
│ ├── test_main_startup.py
│ └── test_todos.py
├── requirements.txt
├── Dockerfile
├── docker-compose.yml
├── PLANNING.md # ← you are here
└── RUNNING.md
```

## Database Layer

The database module (`app/database.py`) exposes:

- **`engine`** – `create_engine("sqlite://", connect_args={"check_same_thread": False})`
- **`SessionLocal`** – `sessionmaker(autocommit=False, autoflush=False, bind=engine)`
- **`Base`** – `declarative_base()` used by all models
- **`get_db()`** – generator dependency for FastAPI `Depends(get_db)`

`connect_args={"check_same_thread": False}` is **required** because SQLite
by default only allows the creating thread to access the connection, but
FastAPI/uvicorn may serve requests from a different thread.

## Data Model

### `todos` table (SQLAlchemy model: `Todo`)

| Column | Type | Constraints |
|-------------|---------------|------------------------------------------|
| id | Integer | PRIMARY KEY, AUTOINCREMENT |
| title | String(255) | NOT NULL |
| description | String(1024) | NULLABLE |
| completed | Boolean | NOT NULL, DEFAULT False |
| created_at | DateTime | NOT NULL, DEFAULT `datetime.utcnow` |

> `created_at` is **server-set** and is never accepted from client input.

## Pydantic Schemas

### `TodoCreate` (POST body)

| Field | Type | Constraints |
|-------------|-----------------|------------------------------|
| title | str | required, min_length=1 |
| description | Optional[str] | default None |
| completed | bool | default False |

> Title must have a **minimum length of 1** to prevent empty-string titles.

### `TodoUpdate` (PUT body)

| Field | Type | Constraints |
|-------------|-----------------|------------------------------|
| title | Optional[str] | min_length=1 if provided |
| description | Optional[str] | default None |
| completed | Optional[bool] | default None |

> Uses **partial update semantics**: only provided (non-None) fields are
> written; fields not supplied in the JSON body remain unchanged.

### `TodoResponse` (all responses)

| Field | Type |
|-------------|-----------------|
| id | int |
| title | str |
| description | Optional[str] |
| completed | bool |
| created_at | datetime |

Config: `from_attributes = True` (Pydantic v2 orm_mode equivalent).

## API Endpoints

| Method | Path | Status | Description |
|--------|-----------------|--------|------------------------|
| GET | `/todos` | 200 | List all todos |
| GET | `/todos/{id}` | 200 | Get one todo |
| POST | `/todos` | 201 | Create a new todo |
| PUT | `/todos/{id}` | 200 | Update an existing todo|
| DELETE | `/todos/{id}` | 204 | Delete a todo |

## Router Functions

Each endpoint handler receives the DB session via
`db: Session = Depends(get_db)` and delegates to straight SQLAlchemy
queries (no repository layer needed at this scale).

## Application Startup

```python
@asynccontextmanager
async def lifespan(app: FastAPI):
Base.metadata.create_all(bind=engine)
yield
```

This ensures all tables are created when the process starts.

## Error Handling

Missing resources raise:

```python
raise HTTPException(status_code=404, detail="Todo not found")
```

Validation errors are handled automatically by FastAPI / Pydantic (422).

## Testing Strategy

- Use `fastapi.testclient.TestClient` for synchronous integration tests.
- Each test file recreates tables via `Base.metadata.create_all` /
`Base.metadata.drop_all` in an `autouse` fixture.
- Unit tests verify column definitions, defaults, and constraints.
- Integration tests hit every endpoint and cover 404 / validation paths.

## Docker Configuration

A `Dockerfile` builds a slim Python image; `docker-compose.yml` maps
port 8000 and runs `uvicorn app.main:app --host 0.0.0.0 --port 8000`.
69 changes: 59 additions & 10 deletions RUNNING.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,31 +3,80 @@
## Prerequisites

- Python 3.10 or later
- pip (Python package manager)
- (Optional) Docker & Docker Compose

## Install dependencies
---

## Option 1 — Local Setup

### 1. Install Dependencies

```bash
pip install -r requirements.txt
```

### 2. Run the Server

```bash
uvicorn app.main:app --reload
```

The API is available at **http://localhost:8000**.

Interactive API documentation (Swagger UI) is at **http://localhost:8000/docs**.

Alternative ReDoc documentation is at **http://localhost:8000/redoc**.

### 3. Seed Sample Data

To populate the database with sample todo items for quick testing:

```bash
pip install fastapi uvicorn pydantic
python seed_data.py
```

For running the test suite you will also need:
This inserts several example todos so you can immediately exercise the
API without manually creating items first. Run it **after** the server
has started at least once (so the database tables exist), or the script
will create the tables itself.

### 4. Run Tests

```bash
pip install httpx pytest
pytest -v
```

## Start the server
---

## Option 2 — Docker

### 1. Build & Start

```bash
uvicorn main:app --reload --host 0.0.0.0 --port 8000
docker compose up --build
```

The API will be available at <http://localhost:8000>.
The API is available at **http://localhost:8000**.

Interactive docs are served at <http://localhost:8000/docs>.
Interactive API documentation (Swagger UI) is at **http://localhost:8000/docs**.

## Run the tests
### 2. Run Tests (inside the container)

```bash
pytest tests/
docker compose exec app pytest -v
```

---

## Notes

- **No authentication** is required — this is a public Todo API.
- The database is **SQLite in-memory** — all data is lost when the
process restarts.
- CORS is configured to allow all origins (`*`) for development
convenience.
- The seed script connects to the same in-memory database **only when
run inside the same process** (e.g., during tests). When running
against a live server, use the API endpoints or switch to a
file-based SQLite URL.
6 changes: 6 additions & 0 deletions app/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
"""Todo application package.

This package contains the FastAPI application, database layer,
Pydantic schemas, SQLAlchemy models, and API routers for the
Todo REST API.
"""
124 changes: 124 additions & 0 deletions app/crud.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
"""CRUD operations for the Todo model.

Provides database-layer functions for creating, reading, updating, and
deleting Todo items. Each function accepts a SQLAlchemy ``Session`` as
its first argument so that callers (typically FastAPI route handlers)
can inject the session via dependency injection.

Functions
---------
- ``get_todos`` – Retrieve a paginated list of todos.
- ``get_todo`` – Retrieve a single todo by primary key.
- ``create_todo`` – Persist a new todo from a ``TodoCreate`` schema.
- ``update_todo`` – Partially update an existing todo from a ``TodoUpdate`` schema.
- ``delete_todo`` – Remove a todo by primary key.
"""

from __future__ import annotations

from typing import List, Optional

from sqlalchemy.orm import Session

from app.models import Todo
from app.schemas import TodoCreate, TodoUpdate


def get_todos(db: Session, skip: int = 0, limit: int = 100) -> List[Todo]:
"""Return a paginated list of Todo items.

Args:
db: Active SQLAlchemy database session.
skip: Number of rows to skip (offset). Defaults to ``0``.
limit: Maximum number of rows to return. Defaults to ``100``.

Returns:
A list of :class:`~app.models.Todo` instances ordered by ``id``.
"""
return db.query(Todo).offset(skip).limit(limit).all()


def get_todo(db: Session, todo_id: int) -> Optional[Todo]:
"""Return a single Todo by its primary key, or ``None`` if not found.

Args:
db: Active SQLAlchemy database session.
todo_id: The integer primary key of the desired todo.

Returns:
The matching :class:`~app.models.Todo` instance, or ``None``.
"""
return db.query(Todo).filter(Todo.id == todo_id).first()


def create_todo(db: Session, todo: TodoCreate) -> Todo:
"""Create and persist a new Todo item.

The ``completed`` flag defaults to ``False`` and ``created_at`` is
set automatically by the database column default.

Args:
db: Active SQLAlchemy database session.
todo: A validated :class:`~app.schemas.TodoCreate` instance.

Returns:
The newly created :class:`~app.models.Todo` with server-set
fields (``id``, ``created_at``) populated.
"""
db_todo = Todo(
title=todo.title,
description=todo.description,
)
db.add(db_todo)
db.commit()
db.refresh(db_todo)
return db_todo


def update_todo(db: Session, todo_id: int, todo: TodoUpdate) -> Optional[Todo]:
"""Partially update an existing Todo item.

Only fields that are explicitly provided (not ``None``) in *todo*
are written to the database. Fields omitted by the client remain
unchanged.

Args:
db: Active SQLAlchemy database session.
todo_id: The integer primary key of the todo to update.
todo: A validated :class:`~app.schemas.TodoUpdate` instance
containing the fields to change.

Returns:
The updated :class:`~app.models.Todo` instance, or ``None`` if
no todo with the given *todo_id* exists.
"""
db_todo = db.query(Todo).filter(Todo.id == todo_id).first()
if db_todo is None:
return None

update_data = todo.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(db_todo, field, value)

db.commit()
db.refresh(db_todo)
return db_todo


def delete_todo(db: Session, todo_id: int) -> bool:
"""Delete a Todo item by primary key.

Args:
db: Active SQLAlchemy database session.
todo_id: The integer primary key of the todo to delete.

Returns:
``True`` if the todo existed and was deleted, ``False`` otherwise.
"""
db_todo = db.query(Todo).filter(Todo.id == todo_id).first()
if db_todo is None:
return False

db.delete(db_todo)
db.commit()
return True
Loading