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
114 changes: 69 additions & 45 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,45 +1,69 @@
Simple Flask Todo App using SQLAlchemy and SQLite database.

For styling [semantic-ui](https://semantic-ui.com/) is used.

### Setup
Create project with virtual environment

```console
$ mkdir myproject
$ cd myproject
$ python3 -m venv venv
```

Activate it
```console
$ . venv/bin/activate
```

or on Windows
```console
venv\Scripts\activate
```

Install Flask
```console
$ pip install Flask
$ pip install Flask-SQLAlchemy
```

Set environment variables in terminal
```console
$ export FLASK_APP=app.py
$ export FLASK_ENV=development
```

or on Windows
```console
$ set FLASK_APP=app.py
$ set FLASK_ENV=development
```

Run the app
```console
$ flask run
```
# Flask Todo App — with REST API

This is a fork of [patrickloeber/flask-todo](https://github.com/patrickloeber/flask-todo), a simple Todo web application built with Flask and SQLAlchemy.

## What is the original app?

The original app is a simple Todo list web application. You open it in your browser, type a task, and click Add. You can also mark tasks as complete or delete them. But all of that only works through the browser — there is no way for another program to talk to it.

## What did I add?

I added a REST API. This means other programs (like Postman, a mobile app, or another website) can now interact with the todos by sending HTTP requests and receiving JSON responses — without touching the browser at all.

This is useful because:
- A mobile app could use the API to sync todos
- Another service could automatically create or delete todos
- Developers can test the app without opening a browser

## How does it work?

The API has 5 routes that cover full CRUD operations:

- GET /api/todos — returns a list of all todos as JSON
- GET /api/todos/id — returns one specific todo by its ID
- POST /api/todos — creates a new todo (send a title in the request body)
- PUT /api/todos/id — updates an existing todo (change title or complete status)
- DELETE /api/todos/id — deletes a todo permanently

All responses are in JSON format. The API also handles errors properly:
- 200 means the request was successful
- 201 means a new todo was successfully created
- 400 means the request was missing something or had bad data
- 404 means the todo ID does not exist

## Files I Added

### api.py
Contains all 5 API routes with full CRUD functionality and error handling.
Uses Flask Blueprint to keep the API separate from the main app code.

### test_api.py
Contains 18 unit tests written with pytest.
Tests cover all CRUD operations with both positive and negative test cases.
Positive tests check that things work correctly.
Negative tests check that errors are handled properly (missing data, wrong ID, etc.)

## How to Run

1. Clone and switch to the feature branch:
git clone https://github.com/CarloGarcellano/flask-todo.git
cd flask-todo
git checkout feature/rest-api

2. Install dependencies:
pip install flask flask-sqlalchemy pytest pytest-cov

3. Run the app:
python app.py

4. Visit the app in your browser:
http://127.0.0.1:5000

5. Test the API:
http://127.0.0.1:5000/api/todos

6. Run the unit tests:
pytest test_api.py -v

## Original Repository
https://github.com/patrickloeber/flask-todo
82 changes: 82 additions & 0 deletions api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
from flask import Blueprint, jsonify, request

api = Blueprint("api", __name__)

def get_db_and_todo():
from app import db, Todo, app
return db, Todo, app

@api.route("/api/todos", methods=["GET"])
def get_todos():
db, Todo, app = get_db_and_todo()
with app.app_context():
todos = Todo.query.all()
return jsonify([
{"id": todo.id, "title": todo.title, "complete": todo.complete}
for todo in todos
]), 200

@api.route("/api/todos/<int:todo_id>", methods=["GET"])
def get_todo(todo_id):
db, Todo, app = get_db_and_todo()
with app.app_context():
todo = Todo.query.get(todo_id)
if todo is None:
return jsonify({"error": "Todo not found"}), 404
return jsonify({
"id": todo.id,
"title": todo.title,
"complete": todo.complete
}), 200

@api.route("/api/todos", methods=["POST"])
def create_todo():
db, Todo, app = get_db_and_todo()
data = request.get_json()
if not data or "title" not in data or not data["title"].strip():
return jsonify({"error": "A 'title' field is required"}), 400
with app.app_context():
new_todo = Todo(title=data["title"].strip(), complete=False)
db.session.add(new_todo)
db.session.commit()
return jsonify({
"id": new_todo.id,
"title": new_todo.title,
"complete": new_todo.complete
}), 201

@api.route("/api/todos/<int:todo_id>", methods=["PUT"])
def update_todo(todo_id):
db, Todo, app = get_db_and_todo()
data = request.get_json()
if not data:
return jsonify({"error": "No data provided"}), 400
with app.app_context():
todo = Todo.query.get(todo_id)
if todo is None:
return jsonify({"error": "Todo not found"}), 404
if "title" in data:
if not data["title"].strip():
return jsonify({"error": "Title cannot be empty"}), 400
todo.title = data["title"].strip()
if "complete" in data:
if not isinstance(data["complete"], bool):
return jsonify({"error": "'complete' must be true or false"}), 400
todo.complete = data["complete"]
db.session.commit()
return jsonify({
"id": todo.id,
"title": todo.title,
"complete": todo.complete
}), 200

@api.route("/api/todos/<int:todo_id>", methods=["DELETE"])
def delete_todo(todo_id):
db, Todo, app = get_db_and_todo()
with app.app_context():
todo = Todo.query.get(todo_id)
if todo is None:
return jsonify({"error": "Todo not found"}), 404
db.session.delete(todo)
db.session.commit()
return jsonify({"message": f"Todo {todo_id} deleted successfully"}), 200
9 changes: 7 additions & 2 deletions app.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,11 @@ def delete(todo_id):
db.session.commit()
return redirect(url_for("home"))


from api import api
app.register_blueprint(api)

if __name__ == "__main__":
db.create_all()
app.run(debug=True)
with app.app_context():
db.create_all()
app.run(debug=True)
159 changes: 159 additions & 0 deletions test_api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import pytest
import json
from app import app, db, Todo


@pytest.fixture
def client():
app.config["TESTING"] = True
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///:memory:"
with app.test_client() as client:
with app.app_context():
db.create_all()
yield client
db.drop_all()


def make_todo(client, title="Test Todo"):
return client.post(
"/api/todos",
data=json.dumps({"title": title}),
content_type="application/json"
)


class TestGetAllTodos:
def test_get_todos_empty(self, client):
db.session.query(Todo).delete()
db.session.commit()
response = client.get("/api/todos")
assert response.status_code == 200
assert response.get_json() == []

def test_get_todos_with_items(self, client):
make_todo(client, "Buy milk")
make_todo(client, "Walk the dog")
response = client.get("/api/todos")
assert response.status_code == 200
assert len(response.get_json()) == 2

def test_get_todos_correct_fields(self, client):
make_todo(client, "Check fields")
todo = client.get("/api/todos").get_json()[0]
assert "id" in todo
assert "title" in todo
assert "complete" in todo


class TestGetOneTodo:
def test_get_existing_todo(self, client):
created = make_todo(client, "Read a book").get_json()
response = client.get(f"/api/todos/{created['id']}")
assert response.status_code == 200
assert response.get_json()["title"] == "Read a book"

def test_get_nonexistent_todo(self, client):
response = client.get("/api/todos/9999")
assert response.status_code == 404
assert "error" in response.get_json()


class TestCreateTodo:
def test_create_todo(self, client):
response = make_todo(client, "Go for a run")
assert response.status_code == 201
assert response.get_json()["title"] == "Go for a run"

def test_create_todo_missing_title(self, client):
response = client.post(
"/api/todos",
data=json.dumps({}),
content_type="application/json"
)
assert response.status_code == 400

def test_create_todo_empty_title(self, client):
response = client.post(
"/api/todos",
data=json.dumps({"title": " "}),
content_type="application/json"
)
assert response.status_code == 400

def test_create_todo_no_body(self, client):
response = client.post("/api/todos", content_type="application/json")
assert response.status_code == 400


class TestUpdateTodo:
def test_update_title(self, client):
created = make_todo(client, "Old title").get_json()
response = client.put(
f"/api/todos/{created['id']}",
data=json.dumps({"title": "New title"}),
content_type="application/json"
)
assert response.status_code == 200
assert response.get_json()["title"] == "New title"

def test_update_complete(self, client):
created = make_todo(client, "Finish homework").get_json()
response = client.put(
f"/api/todos/{created['id']}",
data=json.dumps({"complete": True}),
content_type="application/json"
)
assert response.status_code == 200
assert response.get_json()["complete"] is True

def test_update_nonexistent(self, client):
response = client.put(
"/api/todos/9999",
data=json.dumps({"title": "Ghost"}),
content_type="application/json"
)
assert response.status_code == 404

def test_update_no_body(self, client):
created = make_todo(client, "Some todo").get_json()
response = client.put(
f"/api/todos/{created['id']}",
content_type="application/json"
)
assert response.status_code == 400

def test_update_empty_title(self, client):
created = make_todo(client, "Valid todo").get_json()
response = client.put(
f"/api/todos/{created['id']}",
data=json.dumps({"title": ""}),
content_type="application/json"
)
assert response.status_code == 400

def test_update_wrong_complete_type(self, client):
created = make_todo(client, "Type test").get_json()
response = client.put(
f"/api/todos/{created['id']}",
data=json.dumps({"complete": "yes"}),
content_type="application/json"
)
assert response.status_code == 400


class TestDeleteTodo:
def test_delete_todo(self, client):
created = make_todo(client, "Delete me").get_json()
response = client.delete(f"/api/todos/{created['id']}")
assert response.status_code == 200
assert "deleted" in response.get_json()["message"].lower()

def test_deleted_todo_gone(self, client):
created = make_todo(client, "Temporary").get_json()
client.delete(f"/api/todos/{created['id']}")
response = client.get(f"/api/todos/{created['id']}")
assert response.status_code == 404

def test_delete_nonexistent(self, client):
response = client.delete("/api/todos/9999")
assert response.status_code == 404