Introduction
Application Overview
After completing this course, we will have built a RESTful API using Test Driven Development. The API will following RESTful design principle, using basic HTTP Methods.
Endpoint | HTTP Method | Description |
---|---|---|
/api/todos | GET | Get all todos |
/api/todos | POST | Create todo |
/api/todos/{:id} | GET | Get todo detail |
/api/todos/{:id} | PUT | Update todo |
/api/todos/{:id} | DELETE | Delete todo |
API will be built with Flask as framework and SQLAlchemy as ORM.
Chapter 1
In chapter 1 we'll structurize our project with Flask, SQLAlchemy, Postgres and Docker.
Getting Started
In this chapter we'll setup basic structure for our projects to help our test driven development flow.
Setup
Create new project and requirements with
$ mkdir flask-todo
$ cd flask-todo
We need to initialize our project directory as git repository with command
$ git init
Then we'll use python virtual environment to keep dependencies required by our projects isolated.
$ python -m venv venv
$ source venv/bin/activate
After activating python virtual environment, install required package
(venv)$ pip install flask==2.2.2
To save our project dependencies we can create requirements.txt
containing
Flask==2.2.2
Create app
package inside our project directory with command
(venv)$ mkdir app
(venv)$ touch app/__init__.py
Create Route
helper class in http
package inside our app
(venv)$ mkdir app/http
(venv)$ touch app/http/__init__.py
# app/http/route.py
from __future__ import annotations
from typing import List, Optional, Type, Union
from flask import Blueprint
from flask.typing import RouteCallable
from flask.views import View
class Route:
def __init__(
self,
url_rule: str,
view: Type[View],
method: Optional[str] = None,
name_alias: Optional[str] = None,
) -> None:
self.url_rule = url_rule
self.view = view
self.method = method
self.name_alias = name_alias
def view_func(self) -> RouteCallable:
view_name = self.name_alias or self.view.__name__
return self.view.as_view(view_name)
def methods(self) -> List[str]:
if self.method is None:
methods = getattr(self.view, "methods", None) or ("GET",)
else:
methods = [self.method]
return methods
@classmethod
def get(cls, url_rule: str, view: Type[View], name_alias: Optional[str] = None):
return cls(url_rule=url_rule, view=view, method="GET", name_alias=name_alias)
@classmethod
def post(cls, url_rule: str, view: Type[View], name_alias: Optional[str] = None):
return cls(url_rule=url_rule, view=view, method="POST", name_alias=name_alias)
@classmethod
def put(cls, url_rule: str, view: Type[View], name_alias: Optional[str] = None):
return cls(url_rule=url_rule, view=view, method="PUT", name_alias=name_alias)
@classmethod
def delete(cls, url_rule: str, view: Type[View], name_alias: Optional[str] = None):
return cls(url_rule=url_rule, view=view, method="DELETE", name_alias=name_alias)
@staticmethod
def group(url_prefix_group: str, routes: List[Union[Route, Blueprint]], **kwargs):
name = kwargs.pop("name", None) or url_prefix_group.replace("/", "")
url_prefix = kwargs.pop("url_prefix", None) or url_prefix_group
blueprint = Blueprint(name, __name__, url_prefix=url_prefix, **kwargs)
for route in routes:
if isinstance(route, Blueprint):
blueprint.register_blueprint(route)
else:
blueprint.add_url_rule(
route.url_rule, view_func=route.view_func(), methods=route.methods()
)
return blueprint
To isolate our application configuration create Config
object inside our app
# app/config.py
import os
from functools import lru_cache
class Config:
DEBUG = False
class ProductionConfig(Config):
pass
class DevelopmentConfig(Config):
DEBUG = True
class TestingConfig(Config):
pass
@lru_cache
def get_config():
config = {
"production": ProductionConfig,
"development": DevelopmentConfig,
"testing": TestingConfig,
}
env = os.getenv("APP_ENV", "development")
return config.get(env, DevelopmentConfig)
Then we can create factory.py
file inside app directory
# app/factory.py
from typing import List, Type, Union
from flask import Blueprint, Flask
from app.config import Config
from app.http.route import Route
def create_app(
app_name: str, config: Type[Config], routes: List[Union[Route, Blueprint]]
):
app = Flask(app_name)
app.config.from_object(config)
for route in routes:
if isinstance(route, Blueprint):
app.register_blueprint(route)
else:
app.add_url_rule(
route.url_rule, view_func=route.view_func(), methods=route.methods()
)
return app
Next we will create basic handler for our application, by creating package views
with main_api
module
# app/views/main_api.py
from flask import jsonify
from flask.typing import ResponseReturnValue
from flask.views import View
class MainView(View):
def dispatch_request(self) -> ResponseReturnValue:
resp = {"message": "Application running."}
return jsonify(resp)
Then we can create basic routing for our application, first create routes
package with api
module
# app/routes/api.py
from app.http.route import Route
from app.views.main_api import MainView
api_routes = [
Route.get("/", MainView),
]
Next update our app
package __init__.py
scripts to glue all our code before
# app/__init__.py
from app.config import get_config
from app.factory import create_app
from app.routes.api import api_routes
app = create_app("flask-todo", config=get_config(), routes=api_routes)
Next we'll create script to run our application on root of project
# serve.py
from app import app
if __name__ == "__main__":
app.run()
Run project by invoke command
(venv)$ python serve.py
Navigate to http://localhost:5000 then you should see
{
"message": "Application running."
}
To exclude unwanted files in our codebase repository, we can create .gitignore
file containing
__pycache__/
venv/
Your final project structure should look like this
flask-todo
├── app
│ ├── config.py
│ ├── factory.py
│ ├── http
│ │ ├── __init__.py
│ │ └── route.py
│ ├── __init__.py
│ ├── routes
│ │ ├── api.py
│ │ └── __init__.py
│ └── views
│ ├── __init__.py
│ └── main_api.py
├── .gitignore
├── requirements.txt
└── serve.py
4 directories, 12 files
We can commit our works before continuing to next chapter.
(venv)$ git add .
(venv)$ git commit -m "Initialize project structure"
Docker Setup
This chapter require docker and docker compose installed
Let's put our application inside docker container
Setup Application Container
Create Dockerfile
in our project directory
FROM python:3.8-slim
# Set environment varibles
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1
WORKDIR /usr/src/app
# Install dependencies
RUN pip install --upgrade pip
COPY ./requirements.txt .
RUN pip install -r requirements.txt
COPY . .
Create .dockerignore
file to avoid unnecessarily sending large or sensitive files and directories to the daemon and potentially adding them to images.
__pycache__/
venv/
Dockerfile
env
.env
Create docker-compose.yml
file to manage our container via docker compose
version: '3.8'
services:
todos:
build: .
command: python serve.py
volumes:
- .:/usr/src/app/
ports:
- 5000:5000
env_file:
- ./.env.dev
Create file .env.dev
to add env based configuration
APP_ENV=development
Before build our image, we should add slight changes to our serve.py
from app import app
if __name__ == "__main__":
app.run(host="0.0.0.0")
This will tell our application to use address 0.0.0.0
instead of 127.0.0.1
.
Next we can build our docker with command
$ docker compose build
After finish the build, we can start our services with
$ docker compose up -d
Navigating to http://localhost:5000 should return same response as before
{
"message": "Application running."
}
We can check our container logs with command docker compose logs {container_name} -f
$ docker compose logs todos -f
To shutdown our container we can invoke docker compose down -v
Your final project structure should look like this
flask-todo
├── app
│ ├── config.py
│ ├── factory.py
│ ├── http
│ │ ├── __init__.py
│ │ └── route.py
│ ├── __init__.py
│ ├── routes
│ │ ├── api.py
│ │ └── __init__.py
│ └── views
│ ├── __init__.py
│ └── main_api.py
├── docker-compose.yml
├── Dockerfile
├── .dockerignore
├── .env.dev
├── .gitignore
├── requirements.txt
└── serve.py
4 directories, 16 files
We can commit our works before continuing to next chapter.
(venv)$ git add .
(venv)$ git commit -m "Docker Setup"
PostgreSQL Setup
In this chapter we'll setup PostgreSQL as primary database in our application.
Setup PostgreSQL Container
Create db
directory inside our project workspaces
(venv)$ mkdir db
(venv)$ touch ./db/create.sql
Update create.sql
file with following content
CREATE DATABASE todos_production;
CREATE DATABASE todos_development;
CREATE DATABASE todos_testing;
Create Dockerfile
inside db
directory with following content
FROM postgres:14.6-alpine
COPY create.sql /docker-entrypoint-initdb.d
Update docker-compose.yml
to
This is for educational purpose only, consider to hash your credential before commit your code.
version: '3.8'
services:
postgres:
build: ./db
volumes:
- postgres_data:/var/lib/postgresql/data/
environment:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=password
todos:
build: .
command: python serve.py
volumes:
- .:/usr/src/app/
ports:
- 5000:5000
env_file:
- ./.env.dev
depends_on:
postgres:
condition: service_started
volumes:
postgres_data:
After setup database in docker container, we can add required library to our app for this project.
Update our requirements.txt
to following content
Flask==2.2.2
SQLAlchemy==1.4.45
Flask-SQLAlchemy==3.0.2
Flask-Migrate==4.0.0
psycopg2-binary==2.9.5
Before build our image, update main Dockerfile
to following content
FROM python:3.8-slim
# Set environment varibles
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1
RUN mkdir -p /usr/src/app
WORKDIR /usr/src/app
# Install dependencies
RUN pip install --upgrade pip
COPY ./requirements.txt .
RUN pip install -r requirements.txt
COPY . .
Then we can rebuild our docker image by invoke command
$ docker compose down
$ docker compose build
$ docker compose up -d
To check wether our database created or not, we can invoke psql
from our postgres
container
$ docker compose exec postgres psql -U postgres
When in psql
interface we can invoke \l
to list database in the container
postgres=# \l
List of databases
Name | Owner | Encoding | Collate | Ctype | Access privileges
-------------------+----------+----------+------------+------------+-----------------------
postgres | postgres | UTF8 | en_US.utf8 | en_US.utf8 |
template0 | postgres | UTF8 | en_US.utf8 | en_US.utf8 | =c/postgres +
| | | | | postgres=CTc/postgres
template1 | postgres | UTF8 | en_US.utf8 | en_US.utf8 | =c/postgres +
| | | | | postgres=CTc/postgres
todos_development | postgres | UTF8 | en_US.utf8 | en_US.utf8 |
todos_production | postgres | UTF8 | en_US.utf8 | en_US.utf8 |
todos_testing | postgres | UTF8 | en_US.utf8 | en_US.utf8 |
(6 rows)
Next we can setup database connection on our application config
This is for educational purpose only, consider to hash your credential before commit your code.
import os
from functools import lru_cache
class Config:
DEBUG = False
SQLALCHEMY_TRACK_MODIFICATIONS = False
class ProductionConfig(Config):
SQLALCHEMY_DATABASE_URI = "postgresql://postgres:password@postgres:5432/todos_production"
class DevelopmentConfig(Config):
DEBUG = True
SQLALCHEMY_DATABASE_URI = "postgresql://postgres:password@postgres:5432/todos_development"
class TestingConfig(Config):
TESTING=True
SQLALCHEMY_DATABASE_URI = "postgresql://postgres:password@postgres:5432/todos_testing"
@lru_cache
def get_config():
config = {
"production": ProductionConfig,
"development": DevelopmentConfig,
"testing": TestingConfig,
}
env = os.getenv("APP_ENV", "development")
return config.get(env, DevelopmentConfig)
Then update our factory.py
script to
from typing import List, Type, Union
from flask import Blueprint, Flask
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
from app.config import Config
from app.http.route import Route
db = SQLAlchemy()
migrate = Migrate()
def create_app(
app_name: str, config: Type[Config], routes: List[Union[Route, Blueprint]]
):
app = Flask(app_name)
app.config.from_object(config)
db.init_app(app)
migrate.init_app(app, db)
for route in routes:
if isinstance(route, Blueprint):
app.register_blueprint(route)
else:
app.add_url_rule(
route.url_rule, view_func=route.view_func(), methods=route.methods()
)
return app
Then run migration init to generate migration related scripts
(venv)$ flask db init
This will create directory migrations
to store our next model migrations.
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
│ └── views
│ ├── __init__.py
│ └── main_api.py
├── db
│ ├── create.sql
│ └── Dockerfile
├── docker-compose.yml
├── Dockerfile
├── .dockerignore
├── .env.dev
├── .gitignore
├── migrations
│ ├── alembic.ini
│ ├── env.py
│ ├── README
│ ├── script.py.mako
│ └── versions
├── requirements.txt
└── serve.py
7 directories, 22 files
We can commit our works before continuing to next chapter.
(venv)$ git add .
(venv)$ git commit -m "PostgreSQL Connection Setup"
Chapter 2
In this chapter we'll starting build our features about RESTful API
Create ToDo API Endpoint
In this chapter, we will create endpoint /api/todos
to create Task
based on user needs.
Write the Test
Writing test for Flask application actually like writing function, but it have a few rules
- Test file name should starts with
test_*.py
or end with*_test.py
, in this tutorial we will use latest pattern*_test.py
- Test method or function should start with
test_
- In this tutorial we will cover using python standard library for unit testing
unittest
Before create test case for our project, we need to define our base class that hold our test app. Let's create tests/base.py
import unittest
from app.factory import create_app
from app.config import TestingConfig
from app.routes.api import api_routes
class BaseAPITestCase(unittest.TestCase):
def setUp(self) -> None:
self.app = create_app("test-app", config=TestingConfig, routes=api_routes)
self.client = self.app.test_client()
self.ctx = self.app.app_context()
self.ctx.push()
def tearDown(self) -> None:
self.ctx.pop()
This class will hold our app with TestingConfig
, which connected to our testing specific database todos_testing
.
Let's write our first test by create new file create_task_api_test.py
to tests/features/tasks
directory.
# tests/features/task/create_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 CreateTaskAPITest(BaseAPITestCase):
def test_create_task(self):
endpoint = "/api/todos"
json_data = {
"name": "Fix handrawer",
}
resp = self.client.post(
endpoint,
json=json_data
)
self.assertEqual(resp.status_code, HTTPStatus.CREATED)
tasks = Task.query.all()
self.assertEqual(1, len(tasks))
Task.query.delete()
db.session.commit()
Ensure your container is up and running by invoke command.
$ docker compose up -d
Then run the test with python unittest
module.
$ docker compose exec todos python -m unittest tests/features/task/create_task_api.py
It will return failure message
F
======================================================================
FAIL: test_create_task (tests.features.task.create_task_api.CreateTaskAPITest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/usr/src/app/tests/features/task/create_task_api.py", line 20, in test_create_task
self.assertEqual(resp.status_code, HTTPStatus.CREATED)
AssertionError: 404 != <HTTPStatus.CREATED: 201>
----------------------------------------------------------------------
Ran 1 test in 0.006s
FAILED (failures=1)
This occured because we not define our Task
resource yet. To define Task
resource, first we need to create table to store the data by creating new migration, first we need to define our models.
First we need to create test againts our Task
model, create file tests/unit/task/task_model_test.py
from app.factory import db
from app.tasks.models.task import Task
from tests.base import BaseAPITestCase
class TaskModelTest(BaseAPITestCase):
def test_create_task(self):
task = Task(name="Finish Homework")
db.session.add(task)
db.session.commit()
tasks = Task.query.all()
assert len(tasks) == 1
db.session.delete(task)
db.session.commit()
Then run the test
$ docker compose exec todos python -m unittest tests/unit/task/task_model_test.py
It will return failure message
E
======================================================================
ERROR: task_model_test (unittest.loader._FailedTest)
----------------------------------------------------------------------
ImportError: Failed to import test module: task_model_test
Traceback (most recent call last):
File "/usr/local/lib/python3.8/unittest/loader.py", line 154, in loadTestsFromName
module = __import__(module_name)
File "/usr/src/app/tests/unit/task/task_model_test.py", line 2, in <module>
from app.modules.tasks.models import Task
ImportError: cannot import name 'Task' from 'app.modules.tasks.models' (/usr/src/app/app/modules/tasks/models.py)
----------------------------------------------------------------------
Ran 1 test in 0.000s
FAILED (errors=1)
Create Task
model to file app/tasks/models/task.py
# app/tasks/models/task.py
from app.factory import db
class Task(db.Model): # type: ignore
__tablename__ = "tasks"
id = db.Column(db.BigInteger, primary_key=True)
name = db.Column(db.String(255))
completed = db.Column(db.Boolean, default=False)
created_at = db.Column(db.DateTime, default=db.func.current_timestamp())
updated_at = db.Column(
db.DateTime,
default=db.func.current_timestamp(),
onupdate=db.func.current_timestamp(),
)
Apparently our Models is not known to alembic
migrations until it loaded to our app via routing registration, we need to create empty Handler for our routing.
Create file app/tasks/views/create_task_api.py
contains basic handler view.
# app/tasks/views/create_task_api.py
from flask import jsonify
from flask.typing import ResponseReturnValue
from flask.views import View
from app.tasks.models.task import Task
class CreateTaskAPI(View):
def dispatch_request(self) -> ResponseReturnValue:
return jsonify({})
Then register the handler to our app/routes/api.py
# app/routes/api.py
from app.http.route import Route
from app.tasks.views.create_task_api import CreateTaskAPI
from app.views.main_api import MainView
api_routes = [
Route.get("/", MainView),
Route.group("/api", routes=[
Route.post("/todos", view=CreateTaskAPI)
]),
]
In this routing file, we group our /todos
endpoint to /api
group.
Generate db migration by running command
$ docker compose exec todos flask db migrate -m "create tasks table"
This will create new generated file on migrations/versions/{hash_number}_{message}.py
Then we can run our migration againts testing
environment with command
$ docker compose exec -e APP_ENV=testing todos flask db upgrade
When we run our model test againts newly created table, it will return success
$ docker compose exec todos python -m unittest tests/unit/task/task_model_test.py
It will return message
.
----------------------------------------------------------------------
Ran 1 test in 0.027s
OK
Next try re-running our feature test tests/features/task/create_task_api_test.py
$ docker compose exec todos python -m unittest tests/features/task/create_task_api_test.py
It does not return HTTP Not Found Error (404) anymore but still fail our test
F
======================================================================
FAIL: test_create_task (tests.features.task.create_task_api_test.CreateTaskAPITest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/usr/src/app/tests/features/task/create_task_api_test.py", line 16, in test_create_task
self.assertEqual(resp.status_code, HTTPStatus.CREATED)
AssertionError: 200 != <HTTPStatus.CREATED: 201>
----------------------------------------------------------------------
Ran 1 test in 0.007s
FAILED (failures=1)
Let's make it green by implementing code at app/tasks/views/create_task_api.py
# app/tasks/views/create_task_api.py
from http import HTTPStatus
from flask import jsonify, request
from flask.typing import ResponseReturnValue
from flask.views import View
from app.factory import db
from app.tasks.models.task import Task
class CreateTaskAPI(View):
def dispatch_request(self) -> ResponseReturnValue:
json_data = request.get_json()
task = Task(**json_data)
db.session.add(task)
db.session.commit()
resp = {
"msg": "Created"
}
return jsonify(resp), HTTPStatus.CREATED
Re-run the test tests/features/task/create_task_api_test.py
$ docker compose exec todos python -m unittest tests/features/task/create_task_api_test.py
It will pass
.
----------------------------------------------------------------------
Ran 1 test in 0.031s
OK
But this test only covers happy path only. We need to add tests for following scenario
- Empty json paylod
- Wrong json attributes
Before adding new tests, lets create helper to help invoke our test. Create file bin/test
on our application directory.
#!/usr/bin/env bash
docker compose exec todos python -m unittest $@
Make file executable with
$ chmod +x bin/test
Now we can invoke our test with bin/test unit_test.py
.
Let's add test for empty json payload to our test case
# tests/features/task/create_task_api_test.py
...
def test_create_task_when_payload_empty(self):
endpoint = "/api/todos"
json_data = {}
resp = self.client.post(
endpoint,
json=json_data
)
self.assertEqual(resp.status_code, HTTPStatus.BAD_REQUEST)
Task.query.delete()
db.session.commit()
Re-run test with
$ bin/test tests/features/task/create_task_api_test.py
It will raise error NotNullViolation
, because attribute name is not allowed to be null.
.E
======================================================================
ERROR: test_create_task_when_payload_empty (tests.features.task.create_task_api_test.CreateTaskAPITest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/usr/local/lib/python3.8/site-packages/sqlalchemy/engine/base.py", line 1900, in _execute_context
self.dialect.do_execute(
File "/usr/local/lib/python3.8/site-packages/sqlalchemy/engine/default.py", line 736, in do_execute
cursor.execute(statement, parameters)
psycopg2.errors.NotNullViolation: null value in column "name" of relation "tasks" violates not-null constraint
To validate user request schema, let's add marshmallow
package to our application code base by update requirements.txt
.
Flask==2.2.2
SQLAlchemy==1.4.45
Flask-SQLAlchemy==3.0.2
Flask-Migrate==4.0.0
psycopg2==2.9.5
marshmallow==3.19.0
marshmallow-sqlalchemy==0.28.1
flask-marshmallow==0.14.0
Then rebuild our container with docker compose build
command, then update our running container with docker compose up -d
.
After add flask-marshmallow
package, then register to our application factory.
# app/factory.py
from typing import List, Type, Union
from flask import Blueprint, Flask
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
from flask_marshmallow import Marshmallow
from app.config import Config
from app.http.route import Route
db = SQLAlchemy()
migrate = Migrate()
ma = Marshmallow()
def create_app(
app_name: str, config: Type[Config], routes: List[Union[Route, Blueprint]]
):
app = Flask(app_name)
app.config.from_object(config)
db.init_app(app)
ma.init_app(app)
migrate.init_app(app, db)
for route in routes:
if isinstance(route, Blueprint):
app.register_blueprint(route)
else:
app.add_url_rule(
route.url_rule, view_func=route.view_func(), methods=route.methods()
)
return app
Then create our TaskSchema
on app/tasks/schemas/task_schema.py
# app/tasks/schemas/task_schema.py
from marshmallow import fields, Schema
class TaskSchema(Schema):
id = fields.Integer(dump_only=True)
name = fields.String(required=True)
completed = fields.Boolean()
created_at = fields.DateTime(dump_only=True)
updated_at = fields.DateTime(dump_only=True)
Next implement validation on our view at app/tasks/views/create_task_api.py
# app/tasks/views/create_task_api.py
from http import HTTPStatus
from flask import jsonify, make_response, request
from flask.typing import ResponseReturnValue
from flask.views import View
from app.factory import db
from app.tasks.models.task import Task
from app.tasks.schemas.task_schema import TaskSchema
class CreateTaskAPI(View):
def dispatch_request(self) -> ResponseReturnValue:
task_schema = TaskSchema()
json_data = request.get_json() or dict()
errors = task_schema.validate(json_data)
if errors:
return make_response(
jsonify(errors=str(errors)),
HTTPStatus.BAD_REQUEST
)
task = Task(**json_data)
db.session.add(task)
db.session.commit()
resp = {
"data": task_schema.dump(task)
}
return jsonify(resp), HTTPStatus.CREATED
Re-run test with
$ bin/test tests/features/task/create_task_api_test.py
Test passing
..
----------------------------------------------------------------------
Ran 2 tests in 0.041s
OK
Next let's create test by passing wrong attributes
...
def test_create_task_when_payload_has_unknown_attribute(self):
endpoint = "/api/todos"
json_data = {
"name": "Fix new car",
"completed": False,
"other": "value",
}
resp = self.client.post(
endpoint,
json=json_data
)
self.assertEqual(resp.status_code, HTTPStatus.BAD_REQUEST)
Task.query.delete()
db.session.commit()
Re-run test with
$ bin/test tests/features/task/create_task_api_test.py
Test passing
...
----------------------------------------------------------------------
Ran 3 tests in 0.052s
OK
After our scenario has been implemented, we can run all our test we've created so far by running command
$ bin/test discover -s tests -p '*_test.py'
Ensure all test are passed
....
----------------------------------------------------------------------
Ran 4 tests in 0.064s
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
│ │ └── __init__.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
│ └── __init__.py
├── __init__.py
└── unit
├── __init__.py
└── task
├── __init__.py
└── task_model_test.py
17 directories, 39 files
We can commit our works before continuing to next chapter.
(venv)$ git add .
(venv)$ git commit -m "Create CreateTaskAPI endpoint"
List ToDo API
In this chapter we will create endpoint /api/todos
to list all created Task
in our database.
Write the Test
First let's create test at tests/features/task/list_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 ListTaskAPITest(BaseAPITestCase):
def test_list_task_api(self):
endpoint = "/api/todos"
task_1 = Task(name="Finish Homework")
task_2 = Task(name="Clean up closet")
db.session.add_all([task_1, task_2])
db.session.commit()
resp = self.client.get(endpoint)
self.assertEqual(resp.status_code, HTTPStatus.OK)
resp_json = resp.json or dict()
data = resp_json["data"]
self.assertEqual(data[0]["name"], "Finish Homework")
self.assertEqual(data[1]["name"], "Clean up closet")
Task.query.delete()
db.session.commit()
Invoke test by running command
$ bin/test tests/features/task/list_task_api_test.py
It will return error
F
======================================================================
FAIL: test_list_task_api (tests.features.task.list_task_api_test.ListTaskAPITest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/usr/src/app/tests/features/task/list_task_api_test.py", line 17, in test_list_task_api
self.assertEqual(resp.status_code, HTTPStatus.OK)
AssertionError: 405 != <HTTPStatus.OK: 200>
----------------------------------------------------------------------
Ran 1 test in 0.019s
FAILED (failures=1)
It raise error 405 (HTTP Method Not Allowed) since we not implement our routes yet.
Let's implement our routes at file app/tasks/views/list_task_api.py
from http import HTTPStatus
from flask import jsonify
from flask.typing import ResponseReturnValue
from flask.views import View
from app.factory import db
from app.tasks.models.task import Task
from app.tasks.schemas.task_schema import TaskSchema
class ListTaskAPI(View):
def dispatch_request(self) -> ResponseReturnValue:
tasks_schema = TaskSchema(many=True)
tasks = Task.query.all()
resp = {
"data": tasks_schema.dump(tasks)
}
return jsonify(resp), HTTPStatus.OK
Then register it app/routes/api.py
from app.http.route import Route
from app.tasks.views.list_task_api import ListTaskAPI
from app.tasks.views.create_task_api import CreateTaskAPI
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),
]),
]
Let's re run the test
$ bin/test tests/features/task/list_task_api_test.py
It should passed
.
----------------------------------------------------------------------
Ran 1 test in 0.031s
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 5 tests in 0.084s
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
│ │ ├── __init__.py
│ │ └── list_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
│ ├── __init__.py
│ └── list_task_api_test.py
├── __init__.py
└── unit
├── __init__.py
└── task
├── __init__.py
└── task_model_test.py
17 directories, 41 files
We can commit our works before continuing to next chapter.
(venv)$ git add .
(venv)$ git commit -m "Create ListTaskAPI endpoint"
Detail ToDo API
In this chapter we will create endpoint /api/todos/{:id}
to get detail about specific Task
Write the Test
Write test at tests/features/task/detail_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 DetailTaskAPITest(BaseAPITestCase):
def test_detail_task(self):
endpoint = "/api/todos"
task = Task(name="Buy groceries")
db.session.add(task)
db.session.commit()
db.session.refresh(task)
resp = self.client.get(f"{endpoint}/{task.id}")
self.assertEqual(resp.status_code, HTTPStatus.OK)
resp_json = resp.json or dict()
data = resp_json["data"]
self.assertEqual(data["name"], "Buy groceries")
Task.query.delete()
db.session.commit()
It will raise error HTTP_NOT_FOUND (404), since we not implement our code
F
======================================================================
FAIL: test_create_task (tests.features.task.detail_task_api_test.CreateTaskAPITest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/usr/src/app/tests/features/task/detail_task_api_test.py", line 17, in test_create_task
self.assertEqual(resp.status_code, HTTPStatus.OK)
AssertionError: 404 != <HTTPStatus.OK: 200>
----------------------------------------------------------------------
Ran 1 test in 0.029s
FAILED (failures=1)
Let's implement our code at app/tasks/views/detail_task_api.py
from http import HTTPStatus
from flask import jsonify
from flask.typing import ResponseReturnValue
from flask.views import View
from app.tasks.models.task import Task
from app.tasks.schemas.task_schema import TaskSchema
class DetailTaskAPI(View):
def dispatch_request(self, task_id) -> ResponseReturnValue:
tasks_schema = TaskSchema()
task = Task.query.filter(
Task.id == task_id
).first()
resp = {
"data": tasks_schema.dump(task)
}
return jsonify(resp), HTTPStatus.OK
And update our api routes to
from app.http.route import Route
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.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),
]),
]
Re-run our test file
$ bin/test tests/features/task/detail_task_api_test.py
It should pass
.
----------------------------------------------------------------------
Ran 1 test in 0.033s
OK
Let's add test to cover when id
passed not available
...
def test_detail_task_when_id_not_exists(self):
endpoint = "/api/todos"
resp = self.client.get(f"{endpoint}/123")
self.assertEqual(resp.status_code, HTTPStatus.NOT_FOUND)
Re-run our test file
$ bin/test tests/features/task/detail_task_api_test.py
It should raise error
.F
======================================================================
FAIL: test_detail_task_when_id_not_exists (tests.features.task.detail_task_api_test.DetailTaskAPITest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/usr/src/app/tests/features/task/detail_task_api_test.py", line 29, in test_detail_task_when_id_not_exists
self.assertEqual(resp.status_code, HTTPStatus.NOT_FOUND)
AssertionError: 200 != <HTTPStatus.NOT_FOUND: 404>
----------------------------------------------------------------------
Ran 2 tests in 0.046s
FAILED (failures=1)
Let's change our view to
from http import HTTPStatus
from flask import jsonify
from flask.typing import ResponseReturnValue
from flask.views import View
from app.tasks.models.task import Task
from app.tasks.schemas.task_schema import TaskSchema
class DetailTaskAPI(View):
def dispatch_request(self, task_id) -> ResponseReturnValue:
tasks_schema = TaskSchema()
task = Task.query.filter(
Task.id == task_id
).first_or_404()
resp = {
"data": tasks_schema.dump(task)
}
return jsonify(resp), HTTPStatus.OK
Re-run our test file
$ bin/test tests/features/task/detail_task_api_test.py
It should be pass
..
----------------------------------------------------------------------
Ran 2 tests in 0.047s
OK
Let's re-run all of our tests
$ bin/test discover -s tests -p '*_test.py'
It should pass
.......
----------------------------------------------------------------------
Ran 7 tests in 0.123s
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
│ │ ├── detail_task_api.py
│ │ ├── __init__.py
│ │ └── list_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
│ ├── detail_task_api_test.py
│ ├── __init__.py
│ └── list_task_api_test.py
├── __init__.py
└── unit
├── __init__.py
└── task
├── __init__.py
└── task_model_test.py
17 directories, 43 files
We can commit our works before continuing to next chapter.
(venv)$ git add .
(venv)$ git commit -m "Create DetailTaskAPI endpoint"
Update ToDo API
In this chapter we will create endpoint /api/todos/{:id}
to update our Task
model
Write the Test
Write test at tests/features/task/update_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 UpdateTaskAPITest(BaseAPITestCase):
def test_update_task(self):
endpoint = "/api/todos"
task = Task(name="Buy groceries")
db.session.add(task)
db.session.commit()
db.session.refresh(task)
json_data = {
"name": "Buy milk",
"completed": True,
}
resp = self.client.post(
f"{endpoint}/{task.id}",
json=json_data
)
self.assertEqual(resp.status_code, HTTPStatus.OK)
resp_json = resp.json or dict()
data = resp_json["data"]
self.assertEqual(data["name"], "Buy milk")
self.assertEqual(data["completed"], True)
Task.query.delete()
db.session.commit()
Run our test
$ bin/test tests/features/task/update_task_api_test.py
It will error
F
======================================================================
FAIL: test_update_task (tests.features.task.update_task_api_test.UpdateTaskAPITest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/usr/src/app/tests/features/task/update_task_api_test.py", line 24, in test_update_task
self.assertEqual(resp.status_code, HTTPStatus.OK)
AssertionError: 405 != <HTTPStatus.OK: 200>
----------------------------------------------------------------------
Ran 1 test in 0.032s
FAILED (failures=1)
Let's implement our view at app/tasks/views/update_task_api.py
from http import HTTPStatus
from flask import jsonify, make_response, request
from flask.typing import ResponseReturnValue
from flask.views import View
from app.factory import db
from app.tasks.models.task import Task
from app.tasks.schemas.task_schema import TaskSchema
class UpdateTaskAPI(View):
def dispatch_request(self, task_id) -> ResponseReturnValue:
task_schema = TaskSchema()
json_data = request.get_json() or dict()
task = Task.query.filter(
Task.id == task_id
).first()
task.query.update(json_data)
db.session.commit()
resp = {
"data": task_schema.dump(task)
}
return jsonify(resp), HTTPStatus.OK
Update our routes to
from app.http.route import Route
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),
]),
]
Re-run the test
$ bin/test tests/features/task/update_task_api_test.py
It should pass
.
----------------------------------------------------------------------
Ran 1 test in 0.041s
OK
Let's add test when updated Task
does not exists
...
def test_update_task_when_id_not_exists(self):
endpoint = "/api/todos"
json_data = {
"name": "Buy milk tea",
}
resp = self.client.put(f"{endpoint}/123", json=json_data)
self.assertEqual(resp.status_code, HTTPStatus.NOT_FOUND)
Re-run the test
$ bin/test tests/features/task/update_task_api_test.py
It should fail
.E
======================================================================
ERROR: test_update_task_when_id_not_exists (tests.features.task.update_task_api_test.UpdateTaskAPITest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/usr/src/app/tests/features/task/update_task_api_test.py", line 39, in test_update_task_when_id_not_exists
resp = self.client.put(f"{endpoint}/123", json=json_data)
File "/usr/local/lib/python3.8/site-packages/werkzeug/test.py", line 1150, in put
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/update_task_api.py", line 28, in dispatch_request
task.query.update(json_data)
AttributeError: 'NoneType' object has no attribute 'query'
----------------------------------------------------------------------
Ran 2 tests in 0.057s
FAILED (errors=1)
Fix our implementation to
from http import HTTPStatus
from flask import jsonify, make_response, request
from flask.typing import ResponseReturnValue
from flask.views import View
from app.factory import db
from app.tasks.models.task import Task
from app.tasks.schemas.task_schema import TaskSchema
class UpdateTaskAPI(View):
def dispatch_request(self, task_id) -> ResponseReturnValue:
task_schema = TaskSchema()
json_data = request.get_json() or dict()
task = Task.query.filter(
Task.id == task_id
).first_or_404()
task.query.update(json_data)
db.session.commit()
resp = {
"data": task_schema.dump(task)
}
return jsonify(resp), HTTPStatus.OK
Re-run the test
$ bin/test tests/features/task/update_task_api_test.py
It should pass
..
----------------------------------------------------------------------
Ran 2 tests in 0.056s
OK
Add more test to validate json schema
...
def test_update_task_when_required_payload_empty(self):
endpoint = "/api/todos"
task = Task(name="Buy groceries")
db.session.add(task)
db.session.commit()
db.session.refresh(task)
json_data = {}
resp = self.client.put(
f"{endpoint}/{task.id}",
json=json_data
)
self.assertEqual(resp.status_code, HTTPStatus.BAD_REQUEST)
Task.query.delete()
db.session.commit()
Re-run the test
$ bin/test tests/features/task/update_task_api_test.py
It should fail
..F
======================================================================
FAIL: test_update_task_when_required_payload_empty (tests.features.task.update_task_api_test.UpdateTaskAPITest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/usr/src/app/tests/features/task/update_task_api_test.py", line 55, in test_update_task_when_required_payload_empty
self.assertEqual(resp.status_code, HTTPStatus.BAD_REQUEST)
AssertionError: 200 != <HTTPStatus.BAD_REQUEST: 400>
----------------------------------------------------------------------
Ran 3 tests in 0.074s
FAILED (failures=1)
Update our implementation to
from http import HTTPStatus
from flask import jsonify, make_response, request
from flask.typing import ResponseReturnValue
from flask.views import View
from app.factory import db
from app.tasks.models.task import Task
from app.tasks.schemas.task_schema import TaskSchema
class UpdateTaskAPI(View):
def dispatch_request(self, task_id) -> ResponseReturnValue:
task_schema = TaskSchema()
json_data = request.get_json() or dict()
errors = task_schema.validate(json_data)
if errors:
return make_response(
jsonify(errors=str(errors)),
HTTPStatus.BAD_REQUEST
)
task = Task.query.filter(
Task.id == task_id
).first_or_404()
task.query.update(json_data)
db.session.commit()
resp = {
"data": task_schema.dump(task)
}
return jsonify(resp), HTTPStatus.OK
Re-run the test
$ bin/test tests/features/task/update_task_api_test.py
It should pass
...
----------------------------------------------------------------------
Ran 3 tests in 0.072s
OK
Let's re-run all of our tests
$ bin/test discover -s tests -p '*_test.py'
It should pass
..........
----------------------------------------------------------------------
Ran 10 tests in 0.186s
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
│ │ ├── 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
│ ├── 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, 45 files
We can commit our works before continuing to next chapter.
(venv)$ git add .
(venv)$ git commit -m "Create UpdateTaskAPI endpoint"
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"
Review
After completing Chapter 2 there are few things that we can review
Flask Class-based Views
In this course, we are utilizing Flask's Class-based Views instead of the route decorator.
Flask-SQLAlchemy
Instead defining our own utilization, we utilize Flask-SQLAlchemy to handle our connection and session to database.
Flask-Migrate
To have consistent database structure, we utilize Flask-Migrate to handle our model and migration.
About
Author
Ngalim Siregar is a software engineer who lives and works in Indonesia. He enjoys software development, and loves playing retro games.