From dc814b68cbf1666da923dc20d79965f817b0a7b6 Mon Sep 17 00:00:00 2001 From: CarloGarcellano <202480047@psu.palawan.edu.ph> Date: Fri, 29 May 2026 20:07:15 +0800 Subject: [PATCH] Add REST API with full CRUD operations and unit tests --- README.md | 114 ++++++++++++++++++++++--------------- api.py | 82 +++++++++++++++++++++++++++ app.py | 9 ++- test_api.py | 159 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 317 insertions(+), 47 deletions(-) create mode 100644 api.py create mode 100644 test_api.py diff --git a/README.md b/README.md index e2a98cb..0e8d8a7 100644 --- a/README.md +++ b/README.md @@ -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 \ No newline at end of file diff --git a/api.py b/api.py new file mode 100644 index 0000000..27e060d --- /dev/null +++ b/api.py @@ -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/", 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/", 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/", 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 \ No newline at end of file diff --git a/app.py b/app.py index 4b6ccca..1885646 100644 --- a/app.py +++ b/app.py @@ -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) \ No newline at end of file diff --git a/test_api.py b/test_api.py new file mode 100644 index 0000000..edf6580 --- /dev/null +++ b/test_api.py @@ -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 \ No newline at end of file