How to develop and test a minimal API in Python with Flask

Python Software engineering Flask

11 April, 2023



tl;dr

This post describes how to create a minimal (albeit tested) API in Python using Flask. Inspiration for this project comes from a Udacity course on the same subject. The key difference is that this project is more minimal, with fewer dependencies: There is no React front-end, and we use SQLite instead of PostgreSQL for the database.

The API serves trivia questions.

The full code for this project can be found here. The Flask documentation is definitely worth a look, too.

Project setup

There are no OS dependencies outside of Python 3.x. Create the project folder; create and activate the virtual environment; and install Flask:

mkdir flask-api
cd flask-api
python3 -m venv .env
source .env/bin/activate
pip install flask

Project layout

By convention, our top-level flask-api folder will need flaskr and tests subdirectories. flaskr is where our application code will live, inside a classic __init__.py file:

mkdir flaskr
mkdir tests
cd flaskr
touch __init__.py

Let's use the example from the official documentation to verify that everything is installed and working correctly. Add the following code to __init__.py and save it:

# flask-api/flaskr/__init__.py
from flask import Flask

app = Flask(__name__)

@app.route("/")
def hello():
    return "Hello, World!"

To run the app, make sure you're in the root flask-api directory (within the activated virtual environment), and then run the following from the terminal:

flask --app flaskr run --debug

This tells Flask to run the flaskr app (which is what we've named out API), in debug mode. You should see something like the following:

In your browser, navigate to 127.0.0.1:5000/ and verify that you see the "Hello, World!" string returned on the page.

Test setup

We'll be using the unittest framework for testing, which is included in the Python standard library. In the tests folder, create a file test_api.py, and add the following:

# flask-api/tests/test_api.py
import unittest
from flaskr import HelloWorldTest

class TestAddition(unittest.TestCase):
    def test_add_two(self):
        self.assertEqual(HelloWorldTest.add_two(1), 3)

if __name__ == "__main__":
    unittest.main()

This is a hello-world-style test—we're testing that addition works, and that we can import a function from the flaskr module. Save the file.

We can use unittest's test discovery feature to find all tests from the root flask-api directory. This will find all tests associated with a Python module or package. So, we need to make the tests directory a module, and we do so simply by creating an empty init file: touch tests/__init__.py.

We can then run all tests with python3 -m unittest discover. If we do so now, we should get the following error:

ImportError: cannot import name 'HelloWorldTest' from 'flaskr'

Of course, that's because we haven't created the HelloWorldTest class or the associated add_two function yet! Let's create those now. In flaskr/__init__.py, add the following code:

class HelloWorldTest:
    def add_two(x):
        return x + 2

Simple enough! Now, when we run the tests, it should return something like:

Ran 1 test in 0.000s

OK

This indicates that the tests have all passed successfully, and that 1 + 2 does indeed equal 3.

We've done things this way for a reason: to demonstrate a test-driven-development workflow. This jist is that you write a failing test first, describing what you expect to happen, and then you write the actual code that causes the tests to pass. This ensures that you're maintaining good test coverage of your code base, and also helps to focus your code into specific, testable units.

Create the application factory

Now we're going to refactor flaskr/__init__.py to make use of a Flask application factory. Instead of creating the Flask instance globally, we create it inside a factory function. We also create a database parameter, which we will eventually use to specify a test database in our tests. Replace the __init__.py code with the following:

# flask-api/flaskr/__init__.py
import os
from flask import Flask

def create_app(test_config = None, prod = True):
    app = Flask(__name__)

    # Add configuration keys
    app.config.from_mapping(
        PROD_DATABASE = os.path.join(app.instance_path, "flaskr.sqlite"),
        TEST_DATABASE = os.path.join(app.instance_path, "test-flaskr.sqlite"),
    )

    # Ensure the instance folder exists
    try:
        os.makedirs(app.instance_path)
    except OSError:
        pass

    @app.route("/")
    def hello():
        return "Hello, World!"

    return app

Now, when we run the app with flask --app flaskr run --debug, we should see the same thing we did before.

Database setup

We'll use SQLite (via the sqlite3 library) for the database—it's lightweight and doesn't require us to install any OS dependencies. It's also included in the Python standard library so we don't need to install it separately.

Create a file flaskr/db.py, and add the following code:

# flask-api/flaskr/db.py
import sqlite3
import click
from flask import current_app, g

def get_db(prod = False):
    """
    Return a connection to a sqlite database. Set prod = True for the production
    database, or prod = False (the default) for the test database.
    """
    if prod:
        db_name = current_app.config["PROD_DATABASE"]
    else:
        db_name = current_app.config["TEST_DATABASE"]
    if "db" not in g:
        g.db = sqlite3.connect(
            db_name,
            detect_types = sqlite3.PARSE_DECLTYPES
        )
        g.db.row_factory = sqlite3.Row
    return g.db

def close_db(e = None):
    db = g.pop("db", None)
    if db is not None:
        db.close()

def init_db(prod = False):
    """
    Initialise the production or test database. Set prod = True for the 
    production database, or prod = False (the default) for the test database.
    """
    db = get_db(prod = prod)
    with current_app.open_resource("schema.sql") as f:
        db.executescript(f.read().decode("utf8"))

@click.command("init-test-db")
def init_test_db_command():
    """
    Drop existing and create new tables in the test database.
    """
    init_db()
    click.echo("Initialized the test database.")

@click.command("init-prod-db")
def init_prod_db_command():
    """
    Drop existing and create new tables in the production database.
    """
    init_db(prod = True)
    click.echo("Initialized the production database.")

def init_app(app):
    app.teardown_appcontext(close_db)
    app.cli.add_command(init_test_db_command)
    app.cli.add_command(init_prod_db_command)      

Have a look at the documentation for an explanation of what's going on here. We've slightly modified the original code to allow us to initialise and connect to a test and a production database separately. By default, we'll use the production database when running the application, and the test database when running the test client.

Next, create a file flaskr/schema.sql, and add the following:

# flask-api/flaskr/schema.sql
DROP TABLE IF EXISTS category;
DROP TABLE IF EXISTS question;

CREATE TABLE category (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    type TEXT UNIQUE NOT NULL
);

CREATE TABLE question (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    category_id INTEGER NOT NULL,
    question TEXT UNIQUE NOT NULL,
    answer TEXT NOT NULL,
    difficulty INTEGER NOT NULL,
    FOREIGN KEY (category_id) REFERENCES category (id)
);

INSERT INTO category (type)
VALUES
    ("Science"),
    ("Art"),
    ("Geography"),
    ("History"),
    ("Entertainment"),
    ("Sports");

INSERT INTO question (category_id, question, answer, difficulty)
VALUES
    (1, "What is the heaviest organ in the human body?", "The liver", 4),
    (1, "Who discovered penicillin?", "Alexander Fleming", 4),
    (1, "Hematology is a branch of medicine involving the study of what?", "Blood", 2),
    (2, "Which Dutch graphic artist, initials M C, was a creator of optical illusions?", "Escher", 1),
    (2, "La Giaconda is better known as what?", "Mona Lisa", 3),
    (2, "How many paintings did Van Gogh sell in his lifetime?", "One", 4),
    (2, "Which American artist was a pioneer of Abstract Expressionism, and a leading exponent of action painting?", "Jackson Pollock", 2),
    (3, "What is the largest lake in Africa?", "Lake Victoria", 2),
    (3, "In which royal palace would you find the Hall of Mirrors?", "The Palace of Versailles", 3),
    (3, "The Taj Mahal is located in which Indian city?", "Agra", 2),
    (4, "Whose autobiography is entitled 'I Know Why the Caged Bird Sings'?", "Maya Angelou", 2),
    (4, "What boxer's original name is Cassius Clay?", "Muhammad Ali", 1),
    (4, "Who invented peanut butter?", "George Washington Carver", 2),
    (4, "Which dung beetle was worshipped by the ancient Egyptians?", "Scarab", 4),
    (5, "What movie earned Tom Hanks his third straight Oscar nomination, in 1996?", "Apollo 13", 4),
    (5, "What actor did author Anne Rice first denounce, then praise in the role of her beloved Lestat?", "Tom Cruise", 4),
    (5, "What was the title of the 1990 fantasy directed by Tim Burton about a young man with multi-bladed appendages?", "Edward Scissorhands", 3),
    (6, "Which is the only team to play in every soccer World Cup tournament?", "Brazil", 3),
    (6, "Which country won the first ever soccer World Cup in 1930?", "Uruguay", 4);

This will be the basic database schema for our trivia API: a question table where the questions and answers will live, and with a foreign key to a category table that describes the type of question (e.g., sports, history, etc.).

Finally, add the following to the create_app function in flaskr/__init__.py:

from . import db
db.init_app(app)

Okay, let's initialise our databases. In the terminal, run the following:

flask --app flaskr init-test-db
flask --app flaskr init-prod-db

If everything has worked, we should see Initialized the <production|test> database. returned. There should also now be the database files at flask-api/flaskr/instance/flaskr.sqlite and flask-api/flaskr/instance/test-flaskr.sqlite.

Test the database

At this point, we should verify that we can access the database from within the Flask app. In db.py, we've defined a function get_db, which returns a database connection object. We use that to connect to the database and run SQL queries. In the create_app function, add the following route function:

# flask-api/flaskr/__init__.py
def create_app(test_config = None, prod = True):
    # ...

    @app.route("/questions")
    def get_questions():
        conn = db.get_db(prod = prod)
        cur = conn.cursor()
        res = cur.execute("SELECT question FROM question").fetchone()
        conn.close()
        return f"Question: {res[0]}"

    # ...

    return app

This defines an endpoint at /questions, that returns a single question from the question table. SQLite returns objects in rows, but because we only ask for one row (with fetchone), we can index by res[0], returning the first element—in this case, the only element—which is the question text. Verify that it works by navigating to 127.0.0.1:5000/questions; you should see the text of a question being returned.

JSON response

As is often the case, we'd like our API to return JSON. Flask includes a jsonify function that we can use for this purpose. In __init__.py, import that function, and modify our questions endpoint to fetch all questions, and return a JSON response.

# /flask-api/flaskr/__init__.py
from flask import Flask, jsonify
# ...
def create_app(test_config = None, prod = True):
    # ...

    @app.route("/questions")
    def get_questions():
        conn = db.get_db(prod = prod)
        cur = conn.cursor()
        res = cur.execute("SELECT * FROM question").fetchall()
        conn.close()

        return jsonify({
            "success": True,
            "number_of_questions": len(res)
        })

    # ...

    return app

Navigate to the 127.0.0.1:5000/questions endpoint, and you should see something like this (depending on your browser):

Great—we're returning JSON now.

At this point, we should also verify that we can return the same response via curl. We should see the same sort of response after running the following:

curl 127.0.0.1:5000/questions

Let's modify our response to actually include the question data. To do so, we're going to add a couple of helper functions. The first, format_question, is going to convert the SQL representation of a question object into a Python dictionary (so we can easily encode it as JSON). The second function, paginate_questions, will take a list of question objects, and return a formatted, specific subset of those questions. We place these functions in __init__.py, before the create_app definition:

def format_question(question):
    """
    Take a SQL representation of a question, and return a dictionary.
    """
    return {
        "id": question[0],
        "category_id": question[1],
        "question": question[2],
        "answer": question[3],
        "difficulty": question[4]
    }

def paginate_questions(questions, page = 1):
    """
    Return a formatted list of 5 questions, based on the requested page.
    """
    QUESTIONS_PER_PAGE = 5
    # Define the indices of the first and last questions to return
    start = (page - 1) * QUESTIONS_PER_PAGE
    end = start + QUESTIONS_PER_PAGE
    # Format the selected questions
    formatted_questions = []
    for question in questions[start:end]:
        formatted_questions.append(format_question(question))
    return formatted_questions

Now, update our /questions route (noting that we need to add an extra import, request):

from flask import Flask, jsonify, request
# ...
@app.route("/questions")
def get_questions():
    # Get all questions
    conn = db.get_db(prod = prod)
    cur = conn.cursor()
    res = cur.execute("SELECT * FROM question").fetchall()
    conn.close()

    # Get the requested page (defaults to 1)
    page = request.args.get("page", 1, type = int)

    # Subset the questions 
    questions = paginate_questions(res, page)

    return jsonify({
        "success": True,
        "number_of_questions": len(res),
        "questions": questions,
    })

We now return the full detail of five questions when we hit our /questions endpoint. You'll notice that we also have an optional parameter, page. This defaults to 1, i.e., return the first page (or the first five questions). We can also specify this in the request. For example, you'll get different questions returned with the following two requests:

curl 127.0.0.1:5000/questions
curl 127.0.0.1:5000/questions?page=2

Back to the tests

At this point, let's start writing some tests—for the existing endpoint, and for endpoints that we'd like to create next. Replace the code in test_api.py with the following:

import unittest
from flaskr import create_app
import json

class QuestionsTestCase(unittest.TestCase):
    def setUp(self):
        """
        Set up the test client, using the test database.
        """
        self.app = create_app(prod = False)
        with self.app.app_context():
            db.init_db()
        self.client = self.app.test_client

    def tearDown(self):
        pass

    def test_get_questions(self):
        """
        Test GET /questions
        """
        res = self.client().get("/questions")
        data = json.loads(res.data)
        self.assertEqual(res.status_code, 200)
        self.assertTrue(data["success"])
        self.assertEqual(data["number_of_questions"], 19)
        self.assertEqual(len(data["questions"]), 5)

if __name__ == "__main__":
    unittest.main()

The setUp and tearDown functions are run at the beginning and end of the class's test cases; in this case, we initialise a test client for our tests to run against. Our test_get_questions function makes a GET request to the /questions endpoint, and we are testing that a HTTP 200 status code is returned; that the success key has a value of True; that the number_of_questions key has a value of 19 (which is the number of questions we originally added in schema.sql); and that the number of elements in the questions key is five (which is the number we have set to return per page).

Run python -m unittest discover to verify that everything's working as expected.

More endpoints and tests

Let's add a test case for an endpoint that allows us to look at a specific question:

def test_get_question(self):
    """
    Test GET /questions/. We expect a HTTP 200 response, with JSON data
    including {
        "success": True,
        "question": {
            "id": ,
            "question": , 
            "answer": ,
            "category_id": ,
            "difficulty":     
        }
    }.
    """
    res = self.client().get("/questions/1")
    data = json.loads(res.data)
    self.assertEqual(res.status_code, 200)
    self.assertTrue(data["success"])
    self.assertEqual(data["question"]["id"], 1)
    self.assertEqual(data["question"]["question"], "What is the heaviest organ in the human body?")
    self.assertEqual(data["question"]["answer"], "The liver")
    self.assertEqual(data["question"]["category_id"], 1)
    self.assertEqual(data["question"]["difficulty"], 4)

When we hit 127.0.0.1:5000/questions/<id>, where <id> is an integer corresponding to the ID of a question, we want to return the entire question as JSON (as well as the usual "success" response). We implement this in the following way:

@app.route("/questions/")
def get_question(id):
    # Get the specific question by ID
    conn = db.get_db(prod = prod)
    cur = conn.cursor()
    res = cur.execute(
        f"SELECT * FROM question WHERE id == {id}"
    ).fetchall()
    conn.close()
    
    # If there is no question at the requested ID,
    # abort with a 404 resource not found
    if len(res) == 0:
        abort(404)

    # Format the question
    formatted_question = format_question(res[0])

    # Return the response
    return jsonify({
        "success": True,
        "question": formatted_question,
    })

Note that we need to add another Flask import, abort. This allows us to raise a specific HTTP error code, rather than just an internal server error. Test it out at 127.0.0.1:5000/questions/1 (which should look good), and 127.0.0.1:5000/questions/100 (which should raise a HTTP 404 error, because that question doesn't exist.)

Return errors as JSON

The final thing we want to do in this tutorial is to make sure that any errors we raise are also being returned as JSON. This makes the API much friendlier for clients to use, since they can easily parse error messages if they're in the same format as the expected response. To do so, we use the @app.errorhandler() decorator. In create_app, add the following:

@app.errorhandler(404)
def resource_not_found(error):
    return jsonify({
        "success": False,
        "error": 404,
        "message": "Resource not found"
    })

Now when we navigate to 127.0.0.1:5000/questions/100, we should see a JSON-formatted response:

That's it!

The full code for this project is here; that repo has more endpoints and tests than those that I've described here, as well as some other small differences (such as logging messages); but I'm sure you get the idea by now. Any questions or comments, feel free to add below.



0 comments

Leave a comment