This is the second part of a three-part tutorial on creating REST APIs with Python and Flask. In this part, you will learn how to connect to a database and how to different types of HTTP requests.
Here are the links to other parts of this tutorial.
Connecting to a Database
We will be using a single table in our application to store the details of our users. Let us define the schema for that table first.
Create a file schema.sql
inside the inner profiler directory and put the following contents in it.
DROP TABLE IF EXISTS user;
CREATE TABLE user (
id INTEGER PRIMARY KEY AUTOINCREMENT,
first_name TEXT NOT NULL,
last_name TEXT NOT NULL,
email TEXT UNIQUE NOT NULL,
password TEXT NOT NULL,
created_on TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
- The first line drops the table if it is already present.
id
is our primary key. It is set to auto-increment so that we do not have to set this manually.email
has a unique constraint because we will be using the email to identify the user.first_name
,last_name
andpassword
are non nullable text fields. Their values cannot be empty.created_on
is a timestamp field for storing the account creation time. It has the default value of the current timestamp so the database will put in the creation time for us.
Now we need to connect to a database from python and run our schema file to create the user table.
We will be using the SQLite database. Python standard library comes with SQLite so you do not have to install anything else. In a production system, you should use a fully-featured database server like PostgreSQL or MySQL.
Open the config.py
file we created in the last chapter and update it to include a database name.
SECRET_KEY = "change-this-please"
DATABASE_NAME = "db.sqlite3"
Create a new file, db.py
, in the inner profiler
directory and add the following code.
import sqlite3
import click
from flask import current_app, g
from flask.cli import with_appcontext
def get_db():
if "db" not in g:
g.db = sqlite3.connect(current_app.config["DATABASE_NAME"])
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()
Ignore the imports click
, current_app
and with_appcontext
for now. We will use them later in the chapter. Here is what is going on in the rest of the code.
- The
get_db
is a helper function that returns a connection to a SQLite database. g
is a special object that is global to an app context. An object like a database connection might be used from different functions. Instead of creating this every time a function needs it, we create it the first time when a function asks for it and stores it in theg
object. During subsequent calls, we take the value fromg
and return it directly.- We use
sqlite.Row
to get the query results in dictionary format. By default, SQLite will return tuples. Dictionaries are much easier when working with APIs. - In
close_db
function, we check whether a db object is created and if it is created, we close the connection. close_db
function has a keyword argumente
but we are not using it. This is because flask will call theclose_db
function at the end of a request and flask might pass an error value with the call. We will get an error if we do not accept the error from flask.
Next, we need to load our schema into the database to create tables. We could do it manually but flask gives us a much cleaner way to do operations like this. We will add a new command that can create the schema by reading the SQL file. We can then run this command just like the command we use to run the development server and it will create the table for us.
Add the following code to the end of db.py
file. Do not delete the code that is already in the file.
def init_db():
db = get_db()
with current_app.open_resource("schema.sql") as f:
db.executescript(f.read().decode("utf8"))
@click.command("initialise-database")
@with_appcontext
def initialise_database_command():
init_db()
click.echo("Successfully created database schema.")
- The
init_db
function calls ourget_db
function from previous section to get a database connection. - We use
current_app.open_resource
to open the schema file instead of opening it ourselves.current_app.open_resource
will handle the file path differences so we do not have to worry about the correct path to the file. - We use the
executescript
method in sqlite to run the statements in our file.open_resource
opens the file in binary mode so we need to convert it to a string before executing. We usedecode
for that. - Click is a package we use for creating custom commands. Click gets installed when we install flask so no extra installation is required.
click.command
decorator registers a new command with click. The string we pass to this function will become the command name. When we runflask initialise-database
, click will call theinitialise_database_command
function.with_appcontext
decorator tells flask that this function needs an app context. Without an app context, utilities likeopen_resource
won’t work.echo
is similar to print. We use it to display a message if the command is successful.
Now, we need to tell flask about the command we created and also about our close_db
function. We need to use the flask object to do this but we do not have a flask app object in db.py
. The app object is in our __init__
file. We will create a function that takes the app as an argument and register our commands. Later we will call this function from our __init__
file and pass the app object.
Add the following code to the end of db.py
file.
def init_app(app):
app.teardown_appcontext(close_db)
app.cli.add_command(initialise_database_command)
- We use the
teardown_appcontext
tell flask to callclose_db
function at the of an application context.teardown_appcontext
is useful for closing the resources we opened in the app such as network connections, database connections etc. app.cli.add_command
method adds a new command to flask app.
Next step is to call the init_app
function and pass an app object. For this, we need to update our create_app
function inside __init__.py
. Open profiler/__init__.py
and add the following code after the line app.config.from_pyfile("config.py", silent=True)
from . import db
db.init_app(app)
We can also remove the hello
function. Your __init__.py
file should look like the following.
from flask import Flask
def create_app():
app = Flask(__name__)
app.config.from_pyfile("config.py", silent=True)
from . import db
db.init_app(app)
return app
It is time to test our custom command for creating a database and table. Open a terminal from the outer profiler directory and activate pipenv shell. Then run our command.
flask initialise-database
You should see the success message printed into the terminal.
Blueprints
When we added the hello-world API, we did it inside the create_app
function. But if we have several APIs, putting everything inside the create_app
function is not a feasible solution. Flask has a feature called blueprints which helps us to organise our APIs into multiple modules.
Our API is for managing users in our application. We will implement the following endpoints and methods for that.
Endpoint | Method | Functionality |
---|---|---|
/users/ | POST | Create new user |
/users/ | GET | Get the details of all the users |
/users/user-id/ | GET | Get the details of a single user matching the user-id |
/users/user-id/ | PUT | Update the details of the users matching the user-id |
/users/user-id/ | DELETE | Delete the user matching the user-id |
Create a new file users.py
inside the inner profiler directory and put the following content in it.
from flask import Blueprint
blueprint = Blueprint("users", __name__, url_prefix="/users")
@blueprint.route("/", methods=["POST"])
def create_user():
return {"action": "create user"}, 201
- We created an object of the
Blueprint
class. The first argument is the name of the blueprint. We named it as users. Just like the app object, the blueprint needs to know the module name. We passed the__name__
for that. The third argument, url_prefix is optional. Since all the APIs we define in this blueprint has their URLs starting with /users, we decided to define it as a prefix instead of repeating it with every function. blueprint.route
is a decorator similar toapp.route
. We specified the url as/
since the blueprint will add/users
part.- To
blueprint.route
, we passed a list of methods also. We need to restrict this to only the POST method since that is what clients use for creating a user. Flask will return an error if the method is not POST. - Instead of returning a dictionary, the
create_user
function is returning a tuple containing a dictionary and an integer. The second value in the tuple will be the status code for this API. Conventionally, APIs return the 201 status code if the creation is successful.
The next step is to tell flask app about the new blueprint we created. Open __init__.py
file and add the following lines to the end of create_app
function.
from . import users
app.register_blueprint(users.blueprint)
Your __init__.py
file should look like the following.
from flask import Flask
def create_app():
app = Flask(__name__)
app.config.from_pyfile("config.py", silent=True)
from . import db
db.init_app(app)
from . import users
app.register_blueprint(users.blueprint)
return app
It is time to test our new API. Open a terminal and run the flask server. Now go to postman and load http://127.0.0.1:5000/users/
. Make sure you select the POST method from the dropdown. Otherwise, you will get a method not allowed error.
Let’s now add the remaining APIs. Open users.py
and add the following code to the end of the file.
@blueprint.route("/", methods=["GET"])
def list_users():
return {"action": "list all users"}
@blueprint.route("/<int:user_id>/", methods=["GET"])
def get_user(user_id):
return {"action": f"get user {user_id}"}
@blueprint.route("/<int:user_id>/", methods=["PUT"])
def update_user(user_id):
return {"action": f"update user {user_id}"}
@blueprint.route("/<int:user_id>/", methods=["DELETE"])
def delete_user(user_id):
return "", 204
list_users
is also using the same URL we used forcreate_user
but the method is changed to GET.- For get, update and delete APIs, we added
<int:user_id>
to the URL pattern definition. What this means is that the URL should contain an integer and that integer should be mapped to user_id. Flask will pass the captured user_id to our function. That is why we have an extra user_id argument in these functions. - The list, get and update APIs does not have a status code in the return statement. We want these APIs to return the 200 status code. If we don’t provide a status code, flask will return a 200 status code. You can manually provide a 200 status code but it is not required.
- The return value of
delete_user
is different. We are returning empty string as content and 204 as status code. By convention, the delete operation should not return any content and the status code should be 204.
Now open postman and load http://127.0.0.1:5000/users/
using the GET method. You should see the response from list_users
function. To test get, update and delete we can use the URL http://127.0.0.1:5000/users/1/
. 1 will be the user id. Try it with the GET method and you should see the get_user
response. Now try changing the method PUT and then to DELETE.
User API
The blueprint we built in the last section does not do the actual user management. In this section, we will update the API with real functionality.
We will start with the create API since we need a user created before doing any other operations. Open our user.py
file and update it with the following code. Note that we have made some changes to the import statements also.
from flask import Blueprint, jsonify, request
from werkzeug.security import generate_password_hash
from .db import get_db
blueprint = Blueprint("users", __name__, url_prefix="/users")
@blueprint.route("/", methods=["POST"])
def create_user():
data = request.json
first_name = data.get("first_name")
last_name = data.get("last_name")
email = data.get("email")
password = data.get("password")
if not all([first_name, last_name, email, password]):
return {"error": "Required fields are missing"}, 400
db = get_db()
hashed_password = generate_password_hash(password)
try:
cursor = db.execute(
"INSERT INTO user (first_name, last_name, email, password) VALUES (?, ?, ?, ?)",
(first_name, last_name, email, hashed_password),
)
db.commit()
except db.IntegrityError:
return {"error": "This email is already registered"}, 400
return jsonify({"id": cursor.lastrowid}), 201
- We have imported the
request
object from flask to get the data user sends to the API. Our API will be accepting JSON data as input. To get the JSON data from the request, we need to access itsjson
property. data
is just a dictionary so we can use theget
method to extract values from the dictionary. We used theget
method instead of the subscript notation because we don’t want python to throw an error if a key is not present.- We use the
all
function to make sure all of our input has a value. If any of them is null,all
will returnFalse
. In that case, we will return an error with status code 400. Status code 400 means there is an error in the data sent from the client. - We are using the
get_db
function we created earlier to get a connection to our database. - We don’t want to store the plain text passwords in our database. We will use the
generate_password_hash
function fromwerkzeug
to hash and salt the password.werkzeug
is a dependency for flask so it got installed while you were installing flask. No extra installation is required. - We run our SQL statement using the
execute
method. We need to call thecommit
method for the cursor to update our changes in the database. - When executing the query, we used
?
as a placeholder for the values and then supplied the actual values in a tuple. The sqlite driver will then perform the necessary sanitation in data to protect the API from attacks like SQL injection. - The insert statement will throw an
IntegrityError
if there is already a user with the email id. This is because we set theemail
column as unique while defining our schema. We catch the exception and return an error with the status code 400. - If everything completes without any errors, we get the inserted id using
lastrowid
property and return it.
Save this file and start the flask development server. Then go to postman and put http://127.0.0.1:5000/users/
in the url field and set the method to POST. To send data to the server, select Body
and then select raw
. From the dropdown select JSON
. Now put something like this in the body text field.
{
"first_name": "Harry",
"last_name": "Potter",
"email": "harry@example.com",
"password": "1234"
}
Hit send and you should see the inserted id returned. Try with the same email again and see what happens. Also, try setting an empty value to any of the fields.
Let us update the list_users
function from the last chapter to return the list of all users in our database.
@blueprint.route("/", methods=["GET"])
def list_users():
db = get_db()
cursor = db.execute("SELECT * FROM user")
users = cursor.fetchall()
user_list = []
for user in users:
user_list.append(
{
"id": user["id"],
"first_name": user["first_name"],
"last_name": user["last_name"],
"email": user["email"],
"created_on": user["created_on"],
}
)
return jsonify(user_list)
- We used the
get_db
function to get a database connection and theexecute
method to run our select query. - The
fetchall
method will retrieve all users matching the query. If there are no users in the database, it will return an empty list. - We then loop over the users' data returned from the cursor.
users
will be a list of sqlite Row objects. A Row object is not directly convertible to JSON even though it behaves like a dictionary. Inside the loop, we create a dictionary from the data and append it to our list. Note that we are not including the password in the new dictionary since it should never be returned to the client.
To test our new API, load http://127.0.0.1:5000/users/
. Make sure you set the method to GET.
Now it is time to update get_user
function.
@blueprint.route("/<int:user_id>/", methods=["GET"])
def get_user(user_id):
db = get_db()
cursor = db.execute("SELECT * FROM user WHERE id=?", (user_id,))
user = cursor.fetchone()
if user is None:
return {"error": "Invalid user id"}, 404
return {
"id": user["id"],
"first_name": user["first_name"],
"last_name": user["last_name"],
"email": user["email"],
"created_on": user["created_on"],
}
- We used the select statement with a where clause since we need to fetch only the matching record. Note the comma after
user_id
. This is required to create a single element tuple. - We used the
fetchone
method since we are looking for a single row here. - If there is no matching record in the database,
fetchone
will returnNone
. When this happens, we return an error message with status code 404. 404 for is generally used when a resource is not available in the server. - In the last step, we constructed a dictionary and returned it.
To test this, send a GET request to http://127.0.0.1:5000/users/1/
. Try changing the user id in the URL.
Let us modify update_user
function next.
@blueprint.route("/<int:user_id>/", methods=["PUT"])
def update_user(user_id):
data = request.json
first_name = data.get("first_name")
last_name = data.get("last_name")
email = data.get("email")
if not all([first_name, last_name, email]):
return {"error": "Required fields are missing"}, 400
db = get_db()
try:
db.execute(
"UPDATE user SET first_name=?, last_name=?, email=? WHERE id=?",
(first_name, last_name, email, user_id),
)
db.commit()
except db.IntegrityError:
return {"error": "This email is already registered"}, 400
return jsonify({"id": user_id}), 200
It is mostly similar to our create API with some key differences.
- We removed the password from input fields since changing the password without extra verification steps is not a good idea.
- We are using an update query for updating the details of an already existing user.
Testing this API is also pretty similar to the create API. Add user id to the URL and set the method to PUT.
delete_user
is next in our list to update.
@blueprint.route("/<int:user_id>/", methods=["DELETE"])
def delete_user(user_id):
db = get_db()
db.execute("DELETE FROM user WHERE id=?", (user_id,))
db.commit()
return "", 204
We are not validating the user_id here since it does not matter whether a user exists with that id. If the id is invalid, the delete query will not remove anything from the database.
For testing this, use the same URL you used for testing the single user API but change the method DELETE. After deletion, call the single user API again. You should get an error with 404 status this time.