Skip to content

Todo CRUD API

The Todo resource API will need to provide a RESTFul CRUD interface for the frontend to Create, Read, Update, and Delete Todos. This API is best implemented as a Blueprint, placed in backend/src/backend/blueprints/todos.py, putting all the features e.g. authentication, rate-limiting, models etc... that we've setup together.

Creating the blueprint

The blueprint itself can be created with the following code in backend/src/backend/blueprints/todos.py,

from quart import Blueprint

blueprint = Blueprint("todos", __name__)

and activated by adding the following to backend/src/backend/run.py,

from backend.blueprints.todos import blueprint as todos_blueprint

def create_app() -> Quart:
    ...
    app.register_blueprint(todos_blueprint)

Creating a todo

For a RESTFul API the todo creation route should be POST, expecting the todo data and returning the complete todo with a 201 status code on success, we'll assume that a fast real user can create no more than 10 todos in 10 seconds on average. The following should be added to backend/src/backend/blueprints/todos.py,

from datetime import timedelta

from quart import current_app
from quart_auth import current_user, login_required
from quart_schema import validate_request, validate_response
from quart_rate_limiter import rate_limit

from backend.models.todo import insert_todo, Todo, TodoData

@blueprint.post("/todos/")
@rate_limit(10, timedelta(seconds=10))
@login_required
@validate_request(TodoData)
@validate_response(Todo, 201)
async def post_todo(data: TodoData) -> tuple[Todo, int]:
    """Create a new Todo.

    This allows todos to be created and stored.
    """
    todo = await insert_todo(current_app.db, data, int(current_user.auth_id))
    return todo, 201

Reading a todo

For a RESTFul API the read route should be GET, returning a todo on success (we'll use the same rate limit) and a 404 if the todo does not exist. The following should be added to backend/src/backend/blueprints/todos.py,

from backend.lib.api_error import APIError
from backend.models.todo import select_todo

@blueprint.get("/todos/<int:id>/")
@rate_limit(10, timedelta(seconds=10))
@login_required
@validate_response(Todo)
async def get_todo(id: int) -> Todo:
    """Get a todo.

    Fetch a Todo by its ID.
    """
    todo = await select_todo(current_app.db, id, int(current_user.auth_id))
    if todo is None:
        raise APIError(404, "NOT_FOUND")
    else:
        return todo

Reading the todos

For a RESTFul API the read route should be GET, returning a list of todos on success (we'll use the same rate limit). To demonstrate how to do server side filtering we'll provide a read route that can optionally filter on the complete todo attribute. The following should be added to backend/src/backend/blueprints/todos.py,

from dataclasses import dataclass
from typing import Optional

from quart_schema import validate_querystring

from models.todo import select_todos

@dataclass
class Todos:
    todos: list[Todo]

@dataclass
class TodoFilter:
    complete: Optional[bool] = None

@blueprint.get("/todos/")
@rate_limit(10, timedelta(seconds=10))
@login_required
@validate_response(Todos)
@validate_querystring(TodoFilter)
async def get_todos(query_args: TodoFilter) -> Todos:
    """Get the todos.

    Fetch all the Todos optionally based on the complete status.
    """
    todos = await select_todos(
        current_app.db, int(current_user.auth_id), query_args.complete
    )
    return Todos(todos=todos)

Updating a todo

For a RESTFul API the todo update route should be PUT, expecting the todo data and returning the complete todo on success (we'll use the same rate limit) and a 404 if the todo does not exist. The following should be added to backend/src/backend/blueprints/todos.py,

from models.todo import update_todo

@blueprint.put("/todos/<int:id>/")
@rate_limit(10, timedelta(seconds=10))
@login_required
@validate_request(TodoData)
@validate_response(Todo)
async def put_todo(id: int, data: TodoData) -> Todo:
    """Update the identified todo

    This allows the todo to be replaced with the request data.
    """
    todo = await update_todo(current_app.db, id, data, int(current_user.auth_id))
    if todo is None:
        raise APIError(404, "NOT_FOUND")
    else:
        return todo

Deleting a todo

For a RESTFul API the todo deletion route should be DELETE, returning 202 on success and if the todo does not exist (we'll use the same rate limit). The following should be added to backend/src/backend/blueprints/todos.py,

from quart import ResponseReturnValue

from models.todo import delete_todo

@blueprint.delete("/todos/<int:id>/")
@rate_limit(10, timedelta(seconds=10))
@login_required
async def todo_delete(id: int) -> ResponseReturnValue:
    """Delete the identified todo

    This will delete the todo.
    """
    await delete_todo(current_app.db, id, int(current_user.auth_id))
    return "", 202

Testing

We should test that these routes work as a user would expect, starting by testing that the get route returns the test data todo by adding the following to backend/tests/blueprints/test_todos.py,

import pytest
from quart import Quart

@pytest.mark.asyncio
async def test_get_todo(app: Quart) -> None:
    test_client = app.test_client()
    async with test_client.authenticated("1"):
        response = await test_client.get("/todos/1/")
        todo = await response.get_json()
        assert todo["task"] == "Task"

Next we can test creating, updating, retreiving and then deleting a todo i.e. the full user flow, by adding the following to backend/tests/blueprints/test_todos.py.

@pytest.mark.asyncio
async def test_todo_flow(app: Quart) -> None:
    test_client = app.test_client()
    async with test_client.authenticated("1"):
        response = await test_client.get("/todos/")
        todos = (await response.get_json())["todos"]
        response = await test_client.post(
            "/todos/",
            json={"complete": False, "due": None, "task": "New Todo"},
        )
        todo_id = (await response.get_json())["id"]
        response = await test_client.get("/todos/")
        new_todos = (await response.get_json())["todos"]
        assert len(new_todos) == len(todos) + 1
        await test_client.put(
            f"/todos/{todo_id}/",
            json={"complete": True, "due": None, "task": "New Todo"},
        )
        response = await test_client.get(f"/todos/{todo_id}/")
        todo = await response.get_json()
        assert todo["complete"]
        await test_client.delete(f"/todos/{todo_id}/")
        response = await test_client.get("/todos/")
        final_todos = (await response.get_json())["todos"]
        assert len(final_todos) == len(todos)