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
74 changes: 74 additions & 0 deletions PLANNING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# Todo API — Planning Document

## Overview

A lightweight RESTful Todo API built with **FastAPI** and backed by an
in-memory Python dictionary. Designed for learning, prototyping, and
automated testing — not for production persistence.

## Data Model

### Todo

| Field | Type | Default | Notes |
|--------------|-----------------|-----------------|------------------------------|
| id | int | auto-increment | Primary key, assigned by store |
| title | str | *(required)* | Must be at least 1 character |
| description | Optional[str] | None | Free-text description |
| completed | bool | False | Completion status |
| created_at | str (ISO 8601) | UTC now | Set once at creation time |

## API Endpoints

| Method | Path | Request Body | Success Status | Response Body |
|--------|-------------------|--------------|----------------|----------------------|
| GET | `/` | — | 200 | `{"message": "..."}` |
| POST | `/todos` | TodoCreate | 201 | TodoResponse |
| GET | `/todos` | — | 200 | List[TodoResponse] |
| GET | `/todos/{todo_id}`| — | 200 | TodoResponse |
| PUT | `/todos/{todo_id}`| TodoUpdate | 200 | TodoResponse |
| DELETE | `/todos/{todo_id}`| — | 204 | *(empty)* |

### Error Responses

- **404 Not Found** — returned by GET, PUT, DELETE when `todo_id` does
not exist. Body: `{"detail": "Todo not found"}`.
- **422 Unprocessable Entity** — returned automatically by FastAPI when
the request body fails Pydantic validation.

## Project Structure

```
.
├── main.py # FastAPI app creation, router mounting, uvicorn entry point
├── routes.py # APIRouter with all five CRUD endpoints
├── models.py # Pydantic request/response schemas
├── storage.py # In-memory TodoStore class
├── requirements.txt # Python dependency pins
├── conftest.py # Root pytest configuration
├── tests/
│ ├── test_main.py # Tests for the root endpoint and app wiring
│ └── ... # Additional test modules
├── PLANNING.md # This file
└── RUNNING.md # How to install and run
```

## Design Decisions

1. **In-memory storage** — chosen for simplicity; no external database
dependency. The `TodoStore` class encapsulates all state so it can
be swapped for a persistent backend later.

2. **Auto-incrementing integer IDs** — simple, predictable, easy to test.
A production system might use UUIDs.

3. **Module-level store instance in routes.py** — the store is
instantiated once when the module is imported. Tests reset it via
`store.reset()` to ensure isolation.

4. **Partial updates via PUT with optional fields** — `TodoUpdate` has
all-optional fields; only non-`None` values are applied. This keeps
the endpoint count low while supporting partial changes.

5. **204 No Content for DELETE** — follows REST conventions; the
response body is empty on successful deletion.
26 changes: 25 additions & 1 deletion RUNNING.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
## Install dependencies

```bash
pip install fastapi uvicorn pydantic
pip install -r requirements.txt
```

For running the test suite you will also need:
Expand All @@ -18,14 +18,38 @@ pip install httpx pytest

## Start the server

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

By default the server binds to `127.0.0.1:8000`. You can customise the
host and port:

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

Alternatively, run the application directly with Python:

```bash
python main.py
```

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

Interactive docs are served at <http://localhost:8000/docs>.

## API Endpoints

| Method | Path | Description |
|--------|-------------------|-----------------------|
| GET | `/` | Health / welcome page |
| POST | `/todos` | Create a new todo |
| GET | `/todos` | List all todos |
| GET | `/todos/{todo_id}`| Get a single todo |
| PUT | `/todos/{todo_id}`| Update a todo |
| DELETE | `/todos/{todo_id}`| Delete a todo |

## Run the tests

```bash
Expand Down
19 changes: 11 additions & 8 deletions SETUP.md
Original file line number Diff line number Diff line change
@@ -1,25 +1,28 @@
# Setup Instructions

## Prerequisites

- Python 3.10 or later

## Install Dependencies

```bash
python -m venv .venv
source .venv/bin/activate # On Windows: .venv\Scripts\activate
pip install -r requirements.txt
```

## Install Test Dependencies
## Run the Server

```bash
pip install pytest httpx
uvicorn main:app --reload
```

The API will be available at http://127.0.0.1:8000.
Interactive docs at http://127.0.0.1:8000/docs.

## Run Tests

```bash
pytest tests/ -v
```

## Run the Application

```bash
uvicorn main:app --reload
```
7 changes: 6 additions & 1 deletion main.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

from __future__ import annotations

import uvicorn
from fastapi import FastAPI

from routes import router
Expand All @@ -21,4 +22,8 @@
@app.get("/", tags=["root"])
async def root() -> dict:
"""Return a welcome message at the API root."""
return {"message": "Welcome to the Todo API"}
return {"message": "Todo API is running"}


if __name__ == "__main__":
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)
1 change: 0 additions & 1 deletion models.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@

from __future__ import annotations

from datetime import datetime
from typing import Optional

from pydantic import BaseModel, Field
Expand Down
7 changes: 2 additions & 5 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,6 +1,3 @@
fastapi>=0.100.0
uvicorn>=0.23.0
fastapi>=0.115.0
uvicorn[standard]>=0.30.0
pydantic>=2.0.0
pytest>=7.0.0
pytest-timeout>=2.1.0
httpx>=0.24.0
10 changes: 6 additions & 4 deletions routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@

from __future__ import annotations

from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, HTTPException, Response

from models import TodoCreate, TodoResponse, TodoUpdate
from storage import TodoStore
Expand Down Expand Up @@ -75,14 +75,16 @@ async def update_todo(todo_id: int, payload: TodoUpdate) -> TodoResponse:
return TodoResponse(**todo)


@router.delete("/todos/{todo_id}", status_code=200, tags=["todos"])
async def delete_todo(todo_id: int) -> dict:
@router.delete("/todos/{todo_id}", status_code=204, tags=["todos"])
async def delete_todo(todo_id: int) -> Response:
"""Delete a todo item by its ID.

Returns 204 No Content on success.

Raises:
HTTPException: 404 if the todo is not found.
"""
deleted = store.delete(todo_id)
if not deleted:
raise HTTPException(status_code=404, detail="Todo not found")
return {"detail": "Todo deleted successfully"}
return Response(status_code=204)
43 changes: 21 additions & 22 deletions storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,29 +60,26 @@ def add(
"created_at": now,
}
self._todos[todo_id] = todo
return dict(todo)
return todo

def get(self, todo_id: int) -> Optional[dict]:
"""Retrieve a single todo by its ID.

Args:
todo_id: The integer ID of the todo.
def get_all(self) -> List[dict]:
"""Return a list of all stored todos.

Returns:
A copy of the todo dict, or None if not found.
A list of todo dictionaries ordered by insertion.
"""
todo = self._todos.get(todo_id)
if todo is None:
return None
return dict(todo)
return list(self._todos.values())

def get_all(self) -> List[dict]:
"""Return a list of all todos ordered by ID ascending.
def get(self, todo_id: int) -> Optional[dict]:
"""Retrieve a single todo by its ID.

Args:
todo_id: The unique identifier of the todo.

Returns:
A list of todo dictionaries (copies).
The todo dictionary, or ``None`` if not found.
"""
return [dict(t) for t in self._todos.values()]
return self._todos.get(todo_id)

def update(
self,
Expand All @@ -91,38 +88,40 @@ def update(
description: Optional[str] = None,
completed: Optional[bool] = None,
) -> Optional[dict]:
"""Update fields of an existing todo.
"""Update an existing todo with the supplied fields.

Only non-None arguments are applied.
Only non-``None`` arguments are applied, allowing partial updates.

Args:
todo_id: The integer ID of the todo to update.
todo_id: The unique identifier of the todo to update.
title: New title (if provided).
description: New description (if provided).
completed: New completion status (if provided).

Returns:
A copy of the updated todo dict, or None if not found.
The updated todo dictionary, or ``None`` if the ID was not found.
"""
todo = self._todos.get(todo_id)
if todo is None:
return None

if title is not None:
todo["title"] = title
if description is not None:
todo["description"] = description
if completed is not None:
todo["completed"] = completed
return dict(todo)

return todo

def delete(self, todo_id: int) -> bool:
"""Remove a todo by its ID.

Args:
todo_id: The integer ID of the todo to delete.
todo_id: The unique identifier of the todo to remove.

Returns:
True if the todo was found and deleted, False otherwise.
``True`` if the todo was found and removed, ``False`` otherwise.
"""
if todo_id in self._todos:
del self._todos[todo_id]
Expand Down
Loading