In part 1 of this series, we set up the database and defined the data models for our application.
In this section, we are going to design our API endpoints following the Representational State Transfer(REST) pattern. When web service APIs are designed in a way to adhere to the REST constraints, they are called RESTFul APIs. In such APIs, resources are logically segregated and can be accessed and manipulated using HTTP requests.
Creating the API endpoints
We have to first identify the application’s resources and the actions that we will apply on these resources. The notion of a resource is key in REST. There isn’t a restriction on what can be a resource. Go with what makes the most logical sense.
For this simple application, our resource will be items. Some of the actions we can perform on items include:
- create an item
- get a list of items
- retrieve one item only
- update an item
- delete an item
These actions are essentially CRUD operations and we will implement them using HTTP methods. In REST, each resource generally has a URI ( as we had discussed earlier in part 1) that provides the interface through which interaction with the resource occurs. The nature of these interactions, their effect on the resources so to speak, are defined using HTTP methods.
This is how we will map the API calls to the actions defined above:
GET /items/1
- gets an item with id #1GET /items
- gets a collection of itemsPOST /items
- creates an itemPATCH /items/1
- changes an item with id #1DELETE /items/1
- deletes an item with id #1
The resource will also have a respresentation. Some of the popular representations include:
- XML
- HTML
- JSON
When you request a resource, its represenatation is provided. In this application, we will represent our resource using JSON.
REST URIs should always represent resources in a way that’s meaningful to the client. Think nouns, not verbs, when crafting your URIs, as the example above shows. Consider using plurals.
Note that the HTTP methods should be used for what they are logically intended. For example, a GET should only retrieve an item or a collection of items and should have appropriate return codes.
Building the API interface
To build the rest of the API, we are going to use an extension called Flask-RESTful. This extension integrates seamlessly with flask and will allow us to quickly build the API.
We will first create and move to a new feature branch.
(venv)$ git checkout -b ft-api-endpoints
Install Flask-RESTful with the following command.
(venv)pip install flask-restful
We will now set up flask-restful in our application. Add the following code in
the app.api.v1
package.
# __init__.py
from flask import Blueprint
from flask_restful import Api
bp = Blueprint('catalog', __name__)
api = Api(bp)
from app.api.v1 import views
Here, we have created a new blueprint called catalog and initialized the Api class
from flask restful. This instance is what we will use to create our API
routes. We have also imported a yet to be created views.py
module.
In the __init__.py
file in the app package, update create_app
function as
follows.
# ... previous code
def create_app(config=Config):
# ...
from app.api.v1 import bp
app.register_blueprint(bp, url_prefix="/api/v1")
# ...
The code added above registers the blueprint we had created earlier. All the routes for accessing our API endpoints will be prepended with the url prefix defined in the blueprint registration function.
Let’s now start building up our API endpoints.
In the app.api.v1 package, create a views.py
module and populate it with the
following skeletal code.
# app.api.v1.views
from flask_restful import Resource
from . import api
class ItemAPI(Resource):
def get(self, id=None):
# Return item data
pass
def post(self):
# create new item
pass
def patch(self, id, field):
# make changes to an item on the given field with given id
pass
def delete(self, id):
# Delete item with given ID
pass
api.add_resource(
ItemAPI,
'/items',
'/items/<int:id>'
'/items/<int:id>/<string:field>'
)
To create an API using Flask-RESTful, subclass the Resource class. Then the HTTP methods used to acccess the API are implemented as class methods. The routes are added as indicated in the last line. We can specify multiple routes as needed for any given API.
Unit tests for API endpoints
Before we start building our API views, we are going to write unit tests for all the API endpoints. Writing unit tests lets us check that the code we write works the way we expect it to. Flask provides a test client that simulates requests to the application and returns the response data. This way, we won’t need to run the server.
Also, we should test as much of our code as possible, covering every conceivable path through it. The higher the coverage, the more comfortable we can be that making changes won’t result in undesired consequences.
We will use the pytest testing framework and coverage to test and measure our code.
Install them both.
(venv)$ pip install pytest coverage
Update the requirements.txt
file as follows.
(venv)$ pip freeze | grep -v "pkg-resources" > requirements.txt
The grep -v "pkg-resources"
excludes any name that matches this pattern.
This will prevent installation problems during deployment and continuos
integration later on.
Setup and Fixtures
Lets now add the configuration variables for testing. Update the config.py
module as follows.
# config.py
# ...
class TestConfig(Config):
TESTING = True
SQLALCHEMT_DATABASE_URI = os.getenv('TEST_DATABASE_URL')
Update the .env
file with a new environment variable TEST_DATABASE_URL
to
point to a test database. This is the database we will use for all our tests.
In my case, I have called it catalog_testdb so the database url will have
the following format:
postgresql+psycopg2://jp:catalog12@localhost:5432/catalog_testdb
Setting TESTING to True
tells Flask that the app is in test mode. Flask then
changes some internal behavior to make testing much easier. The installed
Flask extensions also use this flag to make testing them easier.
With the configuration all set up, we will now start writing our test code.
The tests will be located in the tests.v1
package from the application
root.
Pytest has the notion of fixtures which are basically set up functions that
are pre-processed before any test function is executed. The framework has made
a provision for a configuration file of sorts which is used to collect all the
fixtures in one place. This file should be labelled conftest.py
and will be
automatically detected by pytest.
Lets populate this file with our fixtures to set up our tests. Create a
conftest.py
module and add the following code.
# tests.v1.conftest
import pytest
from app import create_app, db
from config import TestConfig
@pytest.fixture
def app():
app = create_app(TestConfig)
with app.app_context():
db.drop_all()
db.create_all()
yield app
db.session.remove()
db.drop_all()
@pytest.fixture
def client(app):
return app.test_client()
The app
fixture will call the create_app
application factory function,
passing TestConfig
to configure the application and the database for
testing.
The client
fixture calls app.test_client()
with the application object
created by the app
fixture. The tests we are going to write will use this
client to make requests to the application without running the server.
To use a fixture, pass the name of the fixture as a parameter to the test function. Pytest will then know to call the fixture function and pass the returned value to the test function in question.
Test modules
Our test code will be in modules that begin with test_
and each function in
the modules will also start with test_
.
We will begin by testing our models.
Create a test_models.py
module and add the following code.
import pytest
from app.models import Item
def test_create_items(app):
with app.app_context():
item1 = Item(itemname='stick', description='hockey stick',
category='hockey')
item2 = Item(itemname='jersey', description='play uniforms',
category='soccer')
db.session.add(item1)
db.session.add(item2)
db.session.commit()
items = Item.query.all()
assert len(items) == 2
assert item1.category == 'hockey'
assert item2.category == 'soccer'
assert bool(item1.createdby) is False
assert bool(item2.createdby) is False
The above module tests whether database operations work correctly. Run pytest and check that all the tests pass.
(venv)$ pytest
Next, we are going to add tests for each of our API endpoints.
Create a new module called test_views.py
. Add the following code to this
module.
# tests.v1.test_views
import json
import pytest
# test data
TEST_ITEM = {'itemname': 'ball', 'category': 'soccer',
'description':'something to kick'}
def test_create_item(client):
response = client.post('/api/v1/items', data=TEST_ITEM)
assert response.status_code == 201
assert response.headers['Location'] is not None
# Invalid requests
# blank fields should not be in input data
response = client.post('/api/v1/items', data={'itemname': '', 'category': '',
'description':''})
assert response.status_code == 400
response = client.post('/api/v1/items', data={'itemname':' ',
'category':' ', 'description':' '})
assert response.status_code == 400
# Test all three fields should be present
response = client.post('/api/v1/items', data={'itemname':'ball', 'category':'soccer'})
assert response.status_code == 400
response = client.post('/api/v1/items', data={'itemname':'jersey'})
assert response.status_code == 400
def test_get_an_item(client):
# Add some test data to the database
response = client.post('/api/v1/items', data=TEST_ITEM)
assert response.status_code == 201
response = client.get('/api/v1/items/1')
assert response.status_code == 200
data = json.loads(response.data.decode('utf-8'))
assert len(data['data']) == 1
item = data['data'][0]
assert len(item) > 0
# test invalid requests
# Non-existent id
response = client.get('/api/v1/items/99999')
assert response.status_code == 404
# Invalid ID type - bad request
response = client.get('/api/v1/items/item1')
assert response.status_code == 400
def test_get_item_collection(client):
response = client.get('/api/v1/items')
assert response.status_code == 200
data = json.loads(response.data.decode('utf-8'))
assert not data['data'] # Nothing yet
# Create some test_items
response = client.post('/api/v1/items', data=TEST_ITEM)
assert response.status_code == 201
response = client.post('/api/v1/items', data=TEST_ITEM)
assert response.status_code == 201
response = client.get('/api/v1/items')
assert response.status_code == 200
data = json.loads(response.data.decode('utf-8'))
assert len(data['data']) == 2
@pytest.mark.parametrize(('path', 'data', 'invalid_field', 'invalid_id'), (
('/api/v1/items/1/itemname',
{'itemname': 'new-name'},
{'category': 'new-category'},
'/api/v1/items/9999/itemname'
),
#
('/api/v1/items/1/category',
{'category': 'new-category'},
{'itemname': 'new-name'},
'/api/v1/items/9999/category'
),
#
('/api/v1/items/1/description',
{'description': 'new-description'},
{'category': 'new-category'},
'/api/v1/items/9999/description'
)))
def test_patch_an_item(client, path, data, invalid_field, invalid_id):
""" Tests for patch endpoint"""
# Item does not exist
response = client.patch(path, data=data)
assert response.status_code == 404
assert b'status' in response.data
assert b'error' in response.data
# create test data
response = client.post('/api/v1/items', data=TEST_ITEM)
assert response.status_code == 201
# patch an item: success
response = client.patch(path, data=data)
assert response.status_code == 200
resp_data = json.loads(response.data.decode('utf-8'))['data'][0]
# check that the value in input data used to update the field is
# present in the json response of this request
assert True in [v in resp_data.values() for v in data.values()]
# Non-matching fields: error
response = client.patch(path, data=invalid_field)
assert response.status_code == 400
assert b'status' in response.data
assert b'error' in response.data
# Too many fields
response = client.patch(path, data={'category':'cricket',
'description': 'bowling game'})
assert response.status_code == 400
assert b'status' in response.data
assert b'error' in response.data
response = client.patch(path,
data={'category':'cricket',
'itemname': 'bat',
'description': 'blowling game'})
assert response.status_code == 400
assert b'status' in response.data
assert b'error' in response.data
# Non-existent id
response = client.patch(invalid_id, data=data)
assert response.status_code == 404
assert b'status' in response.data
assert b'error' in response.data
# invalid update field
response = client.patch('/api/v1/items/1/product',
data=data)
assert response.status_code == 400
assert b'status' in response.data
assert b'error' in response.data
def test_delete_item(client):
# Item does not exist
response = client.delete('/api/v1/items/1')
assert response.status_code == 404
# create test data
response = client.post('/api/v1/items', data=TEST_ITEM)
assert response.status_code == 201
# successful delete
response = client.delete('/api/v1/items/1')
assert response.status_code == 200
data = json.loads(response.data.decode('utf-8'))['data']
assert data[0]['id'] == 1
# Non-existent id
response = client.delete('/api/v1/items/99999')
assert response.status_code == 404
# Invalid id type, should be an int
response = client.delete('/api/v1/items/item-1')
assert response.status_code == 400
The code above details the tests for our endpoints. The tests are mostly self-explanatory.
Each test function takes a client
argument, which is matched to the pytest
fixture we had defined earlier in the conftest.py
module. This argument,
rather the matching pytest fixture, is called and the return value passed to
the test function. This returned values is the client that we then use to make
endpoint requests in our tests. Pytest automatically accomplishes this.
The pytest.mark.parametrize
decorator used to decorate the
test_patch_an_item
function allows us to run this function more than once
using the arguments that have been provided in the decorator. The first tuple
specifies the name of the arguments we’ll have to use in the test function and
the tuples that follow specify matching values for each argument. This is done
to limit code repetition.
Let’s now run the tests.
(venv)$ pytest
The tests should fail.
We are now going to implement each API endpoint such that all the unit tests pertatining to it pass. After implementing each endpoint, run the tests and keep on adding to/modifying the code in question until all the relevant tests pass.
Building the API endpoints
Create a module called views.py
in the app.api.v1
package. Add the
following code to this module.
"""
app.api.v1.views
~~~~~~~~~~~~~~~~~
RESTFul API for Item model
"""
from flask_restful import Resource, reqparse, url_for
from app.models import Item
from app import db
from .common.errors import raise_error
from .common.utils import valid_item_name, valid_category, valid_description
parser = reqparse.RequestParser()
parser.add_argument('itemname', type=str, help='item name not provided')
parser.add_argument('category', type=str, help='category not provided')
parser.add_argument('description', type=str, help='description not provided')
class ItemAPI(Resource):
def post(self):
# create new item
args = parser.parse_args()
item_name = args['itemname']
category = args['category']
description = args['description']
# Validate inout data
if not valid_item_name(item_name):
return raise_error(400, "Invalid item name")
if not valid_category(category):
return raise_error(400, "Invalid category name")
if not valid_description(description):
return raise_error(400, "Invalid description")
item = Item(itemname=item_name, category=category, description=description)
db.session.add(item)
db.session.commit()
uri = url_for('catalog.item', id=item.id, _external=True)
output = {}
output['status'] = 201
output['message'] = 'Created a new item'
data = item.serialize
data['uri'] = uri
output['data'] = [data]
return output, 201, {'Location': uri}
def get(self, id=None):
# Return item data
if id is None:
# Return a collection of all items
all_items = Item.query.all()
output = {}
output['status'] = 200
data = all_items
if all_items:
data = [item.serialize for item in all_items]
output['data'] = data
return output
if not id.isnumeric():
return raise_error(400, "Item ID should be an integer")
item_id = int(id)
item = Item.query.get(item_id)
if not item:
return raise_error(404, "Requested item does not exist")
output = {}
output['status'] = 200
output['data'] = [item.serialize]
return output
def patch(self, id, field):
# update item with given ID
if not id.isnumeric():
return raise_error(400, "Item ID should be an integer")
if field not in ('itemname', 'category', 'description'):
return raise_error(400, "Invalid field name")
parser = reqparse.RequestParser()
parser.add_argument(field, type=str, required=True)
try:
args = parser.parse_args(strict=True)
except:
error_msg = "Invalid input data. Only {} field should be provided".format(field)
return raise_error(400, error_msg)
new_field_value = args.get(field)
if field == 'itemname':
new_field_value = valid_item_name(new_field_value)
elif field == 'category':
new_field_value = valid_category(new_field_value)
elif field == 'description':
new_field_value = valid_description(new_field_value)
if not new_field_value:
return raise_error(400, "{} field is invalid".format(field))
item_id = int(id)
item = Item.query.get(item_id)
if not item:
return raise_error(404, "Item does not exist")
if field == 'itemname':
item.itemname = new_field_value
if field == 'description':
item.description = new_field_value
if field == "category":
item.category = new_field_value
db.session.commit()
output = {}
data = item.serialize
output['status'] = 200
data['message'] = 'successfully updated item {}'.format(field)
output['data'] = [data]
return output
def delete(self, id):
# Delete item with given ID
if not id.isnumeric():
return raise_error(400, "Item ID should be an integer")
item_id = int(id)
item = Item.query.get(item_id)
if not item:
return raise_error(404, "Item does not exist")
db.session.delete(item)
db.session.commit()
output = {}
message = 'Item has been deleted'
output['status'] = 200
output['data'] = [{'id': item_id, 'message': message}]
return output
The module above contains the complete code for the implementation of each
of our endpoints. The class ItemAPI
is used to enclose all the
functionality/actions that will be performed on this resource.
These actions are implemented as class methods that match the name of the http
method that pertains to the action in question.
The post
method is used to implement the create item endpoint. Here, the
reqparse object provided by Flask-Restful has been used to parse and
collect input data into a convenient dictionary. The data is then validated
using validation functions whose implementation we’ll see shortly.
The return value is automatically converted to a json output. This is one of the advantages of using Flask-Restful.
The get
method is used to retrieve one item or a collection of items. The
patch
method is used to update a specified field supplied by the client.
Finally, the get
method is used to delete an item given an ID.
The routes for these endpoints are defined in the __init__.py
file of the
app.api.v1
package. This location provides a convenient location where all
routes can be gathered in one place.
# __init__.py
from flask import Blueprint
from flask_restful import Api
bp = Blueprint('catalog', __name__)
api = Api(bp)
from .views import ItemAPI
# routes for item resource
api.add_resource(
ItemAPI,
'/items',
'/items/<id>',
'/items/<id>/<field>',
endpoint='item'
)
A package called common
has also been defined in the app.api.v1
package.
This package contains modules with helper functions for validations and custom
error generation.
"""
app.api.v1.common.errors
~~~~~~~~~~~~~~~~~~~~~~~~~
Custom error messages
"""
from flask import jsonify
def raise_error(status_code, message):
"""
Return a custom error message
"""
response = jsonify({"status": status_code,
"error": message})
response.status_code = status_code
return response
"""
app.api.v1.common.utils
~~~~~~~~~~~~~~~~~~~~~~~~
some common utility functions
"""
import re
from flask import jsonify
def valid_item_name(name):
if name:
name = name.strip()
return name if name else None
def valid_category(category):
if category:
category = category.strip()
return category if category else None
def valid_description(description):
if description:
description = description.strip()
return description if description else None
Before running our tests, we’ll create a setup.cfg
in the application
root to specify some configuration values that will govern how the
tests will run. This file is largely self-explanatory as shown below.
[tool:pytest]
testpaths = tests
[coverage:run]
branch = True
source =
app
We are now ready to run our tests. Execute the following commands.
pytest
coverage run -m pytest
To view the coverage report on the terminal, run:
coverage report
Note that the above are mostly finished artifacts. In the development process, after writing the tests, one would generally add some code, test the implementaion bit by bit, add some more tests and so on until all the tests pass…I hope you get the picture.
This is generally what Test Driven Development entails. You write tests that you expect will fail, make the tests pass, refactor, rinse repeat. The tests become an integral part of your development process, not just an annoying afterthought.
As a last step, let’s now merge this feature branch to master.
$(venv) git add -A
$(venv) git commit -m "Complete API endpoint implementation
$(venv) git checkout master
$(venv) git merge ft-api-endpoints
$(venv) git push -u origin master
In part 3, we are going to deviate a little and talk about Continuous Integration.