Delete ToDo API

In this chapter we will create endpoint /api/todos/{:id} to delete specific Task

Write the Test

Write our test at tests/features/task/delete_task_api_test.py

from http import HTTPStatus
from tests.base import BaseAPITestCase
from app.tasks.models.task import Task
from app.factory import db


class DeleteTaskAPITest(BaseAPITestCase):
    def test_delete_task(self):
        endpoint = "/api/todos"
        task = Task(name="Buy groceries")
        db.session.add(task)
        db.session.commit()

        db.session.refresh(task)

        resp = self.client.delete(f"{endpoint}/{task.id}")
        self.assertEqual(resp.status_code, HTTPStatus.NO_CONTENT)

        tasks = Task.query.all()
        self.assertEqual(len(tasks), 0)

        Task.query.delete()
        db.session.commit()

Run the test

$ bin/test tests/features/task/delete_task_api_test.py

It should fail

F
======================================================================
FAIL: test_delete_task (tests.features.task.delete_task_api_test.DeleteTaskAPITest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/usr/src/app/tests/features/task/delete_task_api_test.py", line 17, in test_delete_task
    self.assertEqual(resp.status_code, HTTPStatus.NO_CONTENT)
AssertionError: 405 != <HTTPStatus.NO_CONTENT: 204>

----------------------------------------------------------------------
Ran 1 test in 0.033s

FAILED (failures=1)

Create our view at app/tasks/views/delete_task_api.py

from http import HTTPStatus

from flask.typing import ResponseReturnValue
from flask.views import View

from app.factory import db
from app.tasks.models.task import Task


class DeleteTaskAPI(View):
    def dispatch_request(self, task_id) -> ResponseReturnValue:
        task = Task.query.filter(
            Task.id == task_id
        ).first()

        db.session.delete(task)
        db.session.commit()
        return "", HTTPStatus.NO_CONTENT

Update our routes to

from app.http.route import Route
from app.tasks.views.delete_task_api import DeleteTaskAPI
from app.tasks.views.detail_task_api import DetailTaskAPI
from app.tasks.views.list_task_api import ListTaskAPI
from app.tasks.views.create_task_api import CreateTaskAPI
from app.tasks.views.update_task_api import UpdateTaskAPI
from app.views.main_api import MainView

api_routes = [
    Route.get("/", MainView),
    Route.group("/api", routes=[
        Route.post("/todos", view=CreateTaskAPI),
        Route.get("/todos", view=ListTaskAPI),
        Route.get("/todos/<task_id>", view=DetailTaskAPI),
        Route.put("/todos/<task_id>", view=UpdateTaskAPI),
        Route.delete("/todos/<task_id>", view=DeleteTaskAPI),
    ]),
]

Re-run the test

$ bin/test tests/features/task/delete_task_api_test.py

It should pass

.
----------------------------------------------------------------------
Ran 1 test in 0.038s

OK

Let's add test when target id does not exists

    ...
    def test_delete_task_when_id_not_exists(self):
        endpoint = "/api/todos"
        resp = self.client.delete(f"{endpoint}/123")
        self.assertEqual(resp.status_code, HTTPStatus.NOT_FOUND)

Re-run the test

$ bin/test tests/features/task/delete_task_api_test.py

It should fail

.E
======================================================================
ERROR: test_delete_task_when_id_not_exists (tests.features.task.delete_task_api_test.DeleteTaskAPITest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/usr/local/lib/python3.8/site-packages/sqlalchemy/orm/session.py", line 2700, in delete
    state = attributes.instance_state(instance)
AttributeError: 'NoneType' object has no attribute '_sa_instance_state'

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "/usr/src/app/tests/features/task/delete_task_api_test.py", line 27, in test_delete_task_when_id_not_exists
    resp = self.client.delete(f"{endpoint}/123")
  File "/usr/local/lib/python3.8/site-packages/werkzeug/test.py", line 1155, in delete
    return self.open(*args, **kw)
  File "/usr/local/lib/python3.8/site-packages/flask/testing.py", line 223, in open
    response = super().open(
  File "/usr/local/lib/python3.8/site-packages/werkzeug/test.py", line 1094, in open
    response = self.run_wsgi_app(request.environ, buffered=buffered)
  File "/usr/local/lib/python3.8/site-packages/werkzeug/test.py", line 961, in run_wsgi_app
    rv = run_wsgi_app(self.application, environ, buffered=buffered)
  File "/usr/local/lib/python3.8/site-packages/werkzeug/test.py", line 1242, in run_wsgi_app
    app_rv = app(environ, start_response)
  File "/usr/local/lib/python3.8/site-packages/flask/app.py", line 2548, in __call__
    return self.wsgi_app(environ, start_response)
  File "/usr/local/lib/python3.8/site-packages/flask/app.py", line 2528, in wsgi_app
    response = self.handle_exception(e)
  File "/usr/local/lib/python3.8/site-packages/flask/app.py", line 2525, in wsgi_app
    response = self.full_dispatch_request()
  File "/usr/local/lib/python3.8/site-packages/flask/app.py", line 1822, in full_dispatch_request
    rv = self.handle_user_exception(e)
  File "/usr/local/lib/python3.8/site-packages/flask/app.py", line 1820, in full_dispatch_request
    rv = self.dispatch_request()
  File "/usr/local/lib/python3.8/site-packages/flask/app.py", line 1796, in dispatch_request
    return self.ensure_sync(self.view_functions[rule.endpoint])(**view_args)
  File "/usr/local/lib/python3.8/site-packages/flask/views.py", line 107, in view
    return current_app.ensure_sync(self.dispatch_request)(**kwargs)
  File "/usr/src/app/app/tasks/views/delete_task_api.py", line 16, in dispatch_request
    db.session.delete(task)
  File "<string>", line 2, in delete
  File "/usr/local/lib/python3.8/site-packages/sqlalchemy/orm/session.py", line 2702, in delete
    util.raise_(
  File "/usr/local/lib/python3.8/site-packages/sqlalchemy/util/compat.py", line 211, in raise_
    raise exception
sqlalchemy.orm.exc.UnmappedInstanceError: Class 'builtins.NoneType' is not mapped

----------------------------------------------------------------------
Ran 2 tests in 0.056s

FAILED (errors=1)

Update our implementation to

from http import HTTPStatus

from flask.typing import ResponseReturnValue
from flask.views import View

from app.factory import db
from app.tasks.models.task import Task


class DeleteTaskAPI(View):
    def dispatch_request(self, task_id) -> ResponseReturnValue:
        task = Task.query.filter(
            Task.id == task_id
        ).first_or_404()

        db.session.delete(task)
        db.session.commit()
        return "", HTTPStatus.NO_CONTENT

Re-run the test

$ bin/test tests/features/task/delete_task_api_test.py

It should pass

..
----------------------------------------------------------------------
Ran 2 tests in 0.057s

OK

Re-run all test to ensure our new feature not breaking existing feature

$ bin/test discover -s tests -p '*_test.py'

It should pass all tests

............
----------------------------------------------------------------------
Ran 12 tests in 0.233s

OK

Your final project structure should be look like this

flask-todo
├── app
│   ├── config.py
│   ├── factory.py
│   ├── http
│   │   ├── __init__.py
│   │   └── route.py
│   ├── __init__.py
│   ├── routes
│   │   ├── api.py
│   │   └── __init__.py
│   ├── tasks
│   │   ├── __init__.py
│   │   ├── models
│   │   │   ├── __init__.py
│   │   │   └── task.py
│   │   ├── schemas
│   │   │   ├── __init__.py
│   │   │   └── task_schema.py
│   │   └── views
│   │       ├── create_task_api.py
│   │       ├── delete_task_api.py
│   │       ├── detail_task_api.py
│   │       ├── __init__.py
│   │       ├── list_task_api.py
│   │       └── update_task_api.py
│   └── views
│       ├── __init__.py
│       └── main_api.py
├── bin
│   └── test
├── db
│   ├── create.sql
│   └── Dockerfile
├── docker-compose.yml
├── Dockerfile
├── .dockerignore
├── .env.dev
├── .gitignore
├── migrations
│   ├── alembic.ini
│   ├── env.py
│   ├── README
│   ├── script.py.mako
│   └── versions
│       └── 213adaf34061_create_tasks_tables.py
├── requirements.txt
├── serve.py
└── tests
    ├── base.py
    ├── features
    │   ├── __init__.py
    │   └── task
    │       ├── create_task_api_test.py
    │       ├── delete_task_api_test.py
    │       ├── detail_task_api_test.py
    │       ├── __init__.py
    │       ├── list_task_api_test.py
    │       └── update_task_api_test.py
    ├── __init__.py
    └── unit
        ├── __init__.py
        └── task
            ├── __init__.py
            └── task_model_test.py

17 directories, 47 files

We can commit our works before continuing to next chapter.

(venv)$ git add .
(venv)$ git commit -m "Create DeleteTaskAPI endpoint"