Part 6: Pagination, HATEOAS and Parameterized Testing
Tutorial Sections
- Project Overview
- Part 1: Project Setup and Environment Configuration
- Part 2: Database Models, Migrations and JWT Setup
- Part 3: API Configuration and User Registration
- Part 4: JWT Authentication, Decorators and Blacklisting Tokens
- Part 5: RESTful Resources and Advanced Request Parsing
- Part 6: Pagination, HATEOAS and Parameterized Testing
Table of Contents
Github Links for Part 6
Project Structure
The chart below shows the folder structure for this section of the tutorial. In this post, we will work on all files marked as NEW CODE
. Files that contain code from previous sections but will not be modified in this post are marked as NO CHANGES
.
. (project root folder) |- src | |- flask_api_tutorial | |- api | | |- auth | | | |- __init__.py | | | |- business.py | | | |- decorators.py | | | |- dto.py | | | |- endpoints.py | | | | | |- widgets | | | |- __init__.py | | | |- business.py | | | |- dto.py | | | |- endpoints.py | | | | | |- __init__.py | | | |- models | | |- __init__.py | | |- token_blacklist.py | | |- user.py | | |- widget.py | | | |- util | | |- __init__.py | | |- datetime_util.py | | |- result.py | | | |- __init__.py | |- config.py | |- tests | |- __init__.py | |- conftest.py | |- test_auth_login.py | |- test_auth_logout.py | |- test_auth_register.py | |- test_auth_user.py | |- test_config.py | |- test_create_widget.py | |- test_delete_widget.py | |- test_retrieve_widget.py | |- test_retrieve_widget_list.py | |- test_update_widget.py | |- test_user.py | |- util.py | |- .env |- .gitignore |- .pre-commit-config.yaml |- pyproject.toml |- pytest.ini |- README.md |- run.py |- setup.py |- tox.iniKEY:FOLDERNEW CODENO CHANGESEMPTY FILE
Introduction
In the previous section, we began implementing the Widget
API. I’ve reproduced the table from the previous section which gives the specifications for all endpoints in the widget_ns
namespace:
Table 1 | ||||
Widget API endpoint specifications | ||||
Endpoint Name | URI | HTTP Method | CRUD Operation | Required Token |
---|---|---|---|---|
api.widget_list | /api/v1/widgets | POST | Create a new widget | Admin user |
api.widget_list | /api/v1/widgets | GET | Retrieve a list of widgets | Regular user |
api.widget | /api/v1/widgets/<name> | GET | Retrieve a single widget | Regular user |
api.widget | /api/v1/widgets/<name> | PUT | Update an existing widget | Admin user |
api.widget | /api/v1/widgets/<name> | DELETE | Delete a single widget | Admin user |
At this point, we have only implemented the process to create a new widget
object. Per the specification in Table 1, to do so we created a new endpoint named api.widget_list
that responds to requests sent to /api/v1/widgets
. Clients can create a new widget
object by sending a POST
request to this endpoint. Table 1 indicates that the api.widget_list
endpoint must also support GET
requests in order to allow clients to retrieve a list of widget
objects.
What is the best way to send a list of complex objects to a client in an HTTP response? Before we implement the process/endpoint for retrieving a list of widgets, let’s review the established best practices.
Pagination
When a client sends a GET
request to the api.widget_list
endpoint, they are requesting every widget in the database. As the number of widgets increases, so does the size of the HTTP response. As the size of the response increases, the time required to send and receive the data increases, resulting in a sluggish API.
Obviously, returning every widget in the database at once would be foolish. Instead, REST APIs typically employ pagination to limit the number of items sent in the response to the first 10, 20, etc database items (the maximum number of items per page is controlled by the server).
Clients can navigate through the full set of database objects by including a page
parameter and a per_page
parameter with their request data. Combining the ability to specify the number of items that the server should send per page as well as the page number, the client can retrieve any item from the database and avoid a sluggish response.
An example of a request/response pair containing paginated data is given below:
1 flask-api-tutorial $ http :5000/api/v1/widgets?page=1&per_page=10 Authorization:"Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJleHAiOjE1NjY4MjA3NDksImlhdCI6MTU2NjgxOTg0NCwic3ViIjoiMzUyMDg5N2EtZWQ0My00YWMwLWIzYWYtMmZjMTY3NzE5MTYwIiwiYWRtaW4iOmZhbHNlfQ.AkpscH6QoCrfHYeyJTyouanwyj4KH34f3YmzMnyKKdM"
2
3 GET /api/v1/widgets?page=1&per_page=10 HTTP/1.1
4 Accept: */*
5 Accept-Encoding: gzip, deflate
6 Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJleHAiOjE1NjY4MjA3NDksImlhdCI6MTU2NjgxOTg0NCwic3ViIjoiMzUyMDg5N2EtZWQ0My00YWMwLWIzYWYtMmZjMTY3NzE5MTYwIiwiYWRtaW4iOmZhbHNlfQ.AkpscH6QoCrfHYeyJTyouanwyj4KH34f3YmzMnyKKdM
7 Connection: keep-alive
8 Host: localhost:5000
9 User-Agent: HTTPie/2.0.0
10
11
12 HTTP/1.0 200 OK
13 Access-Control-Allow-Origin: *
14 Content-Length: 1407
15 Content-Type: application/json
16 Date: Mon, 26 Aug 2019 11:45:39 GMT
17 Link: </api/v1/widgets?page=1&per_page=10>; rel="self",
18 </api/v1/widgets?page=1&per_page=10>; rel="first",
19 </api/v1/widgets?page=2&per_page=10>; rel="next",
20 </api/v1/widgets?page=5&per_page=10>; rel="last"
21 Server: Werkzeug/0.16.1 Python/3.7.6
22 Total-Count: 23
23
24 {
25 "links":{
26 "self": "/api/v1/widgets?page=1&per_page=10",
27 "first": "/api/v1/widgets?page=1&per_page=10",
28 "next": "/api/v1/widgets?page=2&per_page=10",
29 "last": "/api/v1/widgets?page=5&per_page=10"
30 },
31 "has_prev": false,
32 "has_next": true,
33 "page": 1
34 "total_pages": 5,
35 "items_per_page": 10,
36 "total_items": 23,
37 "items": [
38 {
39 //...
40 "name": "test"
41 "link": "/api/v1/widgets/test",
42 },
43 //...
44 ]
45 }
In this example, the database contains a total of 23 widget
objects. If the client sends a GET
request to /api/v1/widgets?page=1&per_page=10
, the server response will contain the first ten objects from the database in the order that they were created. (i.e., items #1-10) The client can step through the full set of widget
objects by continually sending GET
requests and incrementing the page
attribute. With page=2
and per_page=10
, the server response will contain the next ten objects from the database (i.e., items #11-20). With page=3
and per_page=10
, only three items will be sent in the response (i.e., items #21-23).
HATEOAS
If you’ve spent time reading about REST APIs, you have probably encountered the term HATEOAS, which stands for Hypertext As The Engine Of Application State. This is part of Roy Fielding’s formal definition of REST, and the main takeaway is that clients shouldn’t need to construct URLs in order to interact with a REST API, since this ties the client to an implementation. This is bad because any change to the structure/syntax of an existing URI is a breaking change for the client.
Ideally, a REST API should provide links in the header or body of the response that direct the client to the next logical set of actions/resources based on the client’s request. The navigational links in the header (Lines 18-21) and body of the response (Lines 25-29) above allow the client to browse through the collection of widgets.
The object named “items” contains the first ten (of 23 total) widget objects. Each widget contains a “link” attribute (Line 38) containing the URI for the widget. Thanks to the link attribute and the navigational links, the client can navigate through the API and perform CRUD actions on widget
instances without manually entering a single endpoint URL.
page
and per_page
are query string parameters. Query string parameters can be parsed using the same techniques that we used to parse email/password values from form data in the auth_ns
namespace, and widget
information in the previous section.
If the client sends a GET
request to http://localhost:5000/api/v1/widgets
(i.e., neither page
or per_page
are sent with the request), what happens? Typically, default values are assumed for these parameters. The default values are configured when we create the RequestParser
arguments for the pagination parameters.
Retrieve Widget List
With the background info regarding pagination and HATEOAS in mind, we are ready to begin implementing the API endpoint that responds to GET
requests sent to /api/v1/widgets
. Per Table 1, this endpoint and request type allows clients to retrieve a list of widget
objects. As before, we start by creating request parsers/API models to validate request data and serialize response data.
pagination_reqparser
Request Parser
When a client sends a request to retrieve a list of widgets
, what data should we expect to be included with the request? The answer should be fairly obvious based on the information covered in the Introduction.
The request to retrieve a list of widget
objects should include two values: the page number and number of items per page. Luckily, both of these values are integers, and there are several pre-built types in the flask_restx.inputs
module that convert request data to integer values.
Open src/flask_api_tutorial/api/widgets/dto.py
and update the import statements to include the flask_restx.inputs.positive
class (Line 6):
|
|
Next, add the content below:
|
|
By specifying type=positive
, the value provided in the request data will be coerced to an integer. If the value represents a positive, non-zero integer, the request will succeed and the server will send the paginated list to the client. Otherwise, the server will reject the request with status code 400 HTTPStatus.BAD_REQUEST
.
This is the first time that we have specified a RequestParser
argument as required=False
. This allows the client to send a request without either parameter and the request will still succeed (e.g., GET /api/v1/widgets
will return the same response as GET /api/v1/widgets?page=1&per_page=10
).
The range of valid values for the page
parameter is any positive integer. However, the per_page
parameter must have an upper bound since the point of employing pagination is to prevent the API from becoming sluggish due to sending/receiving a large amount of data.
Flask-RESTx includes a pre-built type (flask_restx.inputs.int_range
) that will restrict values to a range of integers. This would allow the client to request any number of items per page, but I think it makes more sense to restrict the page size to a small, fixed set of choices.
The list provided to the choices
keyword defines the set of allowable values. This has an additional benefit — on the Swagger UI page, the input form for per_page
will render a select element containing the list of choices.
Implementing the request parser was trivial, but constructing a response containing a list of widget
objects is significantly more complicated. Keep in mind, the response must be formatted as a paginated list, including navigational links and values for the current page number, number of items per page, total number of items in the collection, etc.
It is possible to create the paginated list manually from scratch and define API models to serialize the whole thing to JSON (it would also be tedious). Instead of needlessly wasting time, let’s take a look at a method that will do most of the work for us.
Flask-SQLAlchemy paginate
Method
The Flask-SQLAlchemy extension provides a paginate
method that produces Pagination
objects. The paginate
method is a member of the Query
class, and I think the easiest way to understand how it works is with a demonstration in the interactive shell:
(flask-api-tutorial) flask-api-tutorial $ flask shell
Python 3.7.6 (default, Jan 19 2020, 06:08:58)
[Clang 10.0.1 (clang-1001.0.46.4)] on darwin
App: flask-api-tutorial [development]
Instance: /Users/aaronluna/Projects/flask_api_tutorial/instance
>>> len(Widget.query.all())
6
>>> pagination = Widget.query.paginate(page=1, per_page=5)
>>> pagination
<flask_sqlalchemy.Pagination object at 0x10b44bf90>
I created six Widget
instances in my test environment, which is verified by the first statement len(Widget.query.all())
returning a result of 6. Next, we create a Pagination
object for the first page of Widget
objects with five items per page by calling Widget.query.paginate(page=1, per_page=5)
. The last statement verifies that we did, in fact, create a Pagination
object.
>>> pagination.pages
2
>>> pagination.per_page
5
>>> pagination.total
6
These three attributes will remain the same for all pages with the same per_page
value. pages
is the total number of pages, per_page
is (obviously) the number of items on a single page, and total
is the total number of items in the collection.
>>> pagination.page
1
>>> pagination.has_next
True
>>> pagination.has_prev
False
>>> len(pagination.items)
5
>>> pagination.items
[<Widget name=first_widget, info_url=https://www.widgethost.net>, <Widget name=next, info_url=https://www.thisorthat.feh>, <Widget name=another, info_url=https://www.wontbelong.now>, <Widget name=foo, info_url=https://www.foo.bar>, <Widget name=baz, info_url=https://www.baz.bar>]
Next, we call pagination.page
to verify that the page number matches the value specified in the paginate
method. Since we specified per_page=5
and there are six total items we know that there are two total pages, which is confirmed by the value of pagination.has_next
being True
. Also, since this is the first page we know that there is no previous page, which is why pagination.has_prev
is False
.
len(pagination.items)
verifies that this page contains five widgets
. Finally, we inspect the widget
list directly by executing pagination.items
. As expected, a list containing five Widget
instances is returned.
>>> pagination.items[0].name
'first_widget'
>>> pagination.items[0].owner
<User email=admin@test.com, public_id=475807a4-8497-4c5c-8d70-109b429bb4ef, admin=True>
>>> pagination.items[0].owner.email
'admin@test.com'
Let’s take a look at pagination.items[0]
, the first widget
added to the database. First, the name
attribute is checked, followed by owner
. owner
contains a User
object that corresponds to the user that created this Widget
. The create_widget
function (which performs the process of creating a widget after the request has been fully validated) stores the id
of the User
that sent the request in the Widget.owner_id
attribute.
owner_id
is defined as a SQLAlchemy Column
to which a ForeignKey
construct has been applied and this integer value is stored in the widget
database table. Widget.owner
is defined as a SQLAlchemy relationship between the Widget
table and the User
table, and is not stored in the database. Whenever a Widget
object is retrieved from the database, Widget.owner
is populated with a User
object thanks to the foreign-key relationship and the magic of the SQLAlchemy ORM.
>>> pagination = Widget.query.paginate(page=2, per_page=5)
>>> pagination.page
2
>>> pagination.has_next
False
>>> pagination.has_prev
True
>>> len(pagination.items)
1
>>> pagination.items
[<Widget name=bim, info_url=http://www.baz.bar>]
Finally, we retrieve the second (and final) page of Widget
objects with five items per page by calling Widget.query.paginate(page=2, per_page=5)
. We then verify that this is, in fact, the second page by calling pagination.page
. We know pagination.has_next
should be False
since this is the final page of Widgets
, and pagination.has_prev
should be True
. len(pagination.items)
is one since there are six total Widgets
and items #1-5 were shown on page=1
. Lastly, we verify that pagination.items
contains a single Widget
object.
Hopefully, this helps you understand the structure of the Pagination
class and the behavior of the paginate
method. Understanding both is crucial to implementing the api.widget_list
endpoint. Next, we need to create an API model for the Pagination
class which will be considerably more complex than the API model we created for the User
class.
pagination_model
API Model
In order to send a paginated list of widgets as part of an HTTP response, we need to serialize it to JSON. I explained the purpose of API Models and how Flask-RESTx uses them to serialize database objects in Part 4. If you need a refresher, please review it.
First, we need to update the import statements in src/flask_api_tutorial/api/widgets/dto.py
to include the Flask-RESTx Model
class, as well as a bunch of classes from the fields
module . Add Line 6 and Line 7 and save the file:
|
|
Next, add the content below:
|
|
There is a lot to digest here. This is the first time that we are encountering API models that are composed of other API models. pagination_model
contains a list of widget_model
objects as well as a single pagination_links_model
instance, Also, widget_model
contains a single instance of widget_owner_model
. Let’s take a look at how each API model is defined and how they interact with each other.
widget_owner_model
and widget_model
Let’s work our way from the inside-out. As demonstrated in the interactive shell, the structure of a Pagination
object is:
pagination -> items -> widget -> owner
owner
is a User
object. Previously, we created the user_model
API model to serialize a User
object to JSON. This API model exposes every attribute of the User
class, most of which are unnecessary in this context.
Rather than re-using user_model
, we will create widget_owner_model
which exposes only the email
and public_id
values of the User
object:
widget_owner_model = Model(
"Widget Owner",
{
"email": String,
"public_id": String,
},
)
The widget_model
has a bunch of features that we are seeing for the first time. Let’s take a look at it in more depth:
|
|
Lines 86-87: This is the first time that we are using the
flask_restx.fields.DateTime
class, which formats adatetime
value as a string. There are two supported formats: RFC 822 and ISO 8601. The format which is returned is determined by thedt_format
parameter.By default, ISO 8601 format is used. Since
dt_format
is not specified,created_at_iso8601
will use this format. On the other hand,created_at_rfc822
specifiesdt_format="rfc822"
so the same date will be returned using RFC 822 format.What do these two formats look like? Here's an example:
"created_at_iso8601": "2019-09-20T04:47:50", "created_at_rfc822": "Fri, 20 Sep 2019 04:47:50 -0000",
The benefit of using a standard output format is that it can be easily parsed back to the original
datetime
value. If this is not a requirement and you (like me) find these formats difficult to quickly parse visually, there are other ways to formatdatetime
values within an API model.Line 88:
deadline_str
is a formatted string version ofdeadline
, which is adatetime
value. Sincedeadline
is localized to UTC,deadline_str
converts this value to the local time zone where the code is executed.Here's an example of the format used by
deadline_str
:"deadline": "09/20/19 10:59:59 PM UTC-08:00",
I prefer this style of formatting to either ISO 8601 or RFC 822 format since it is localized to the user's timezone and is (IMO) more readable. However, if the
datetime
value will not be read by humans and/or will be provided to a function expecting either ISO 8601 or RFC 822 format, obviously use the built-inflask_restx.fields.Datetime
class.Line 89: We used the
Boolean
class already (in theuser_model
API model), so refer back to that section if you need to review how it works.Line 90:
time_remaining_str
is a formatted string version oftime_remaining
, which is atimedelta
value. Since Flask-RESTx does not include built-in types for serializingtimedelta
values, formattingtime_remaining
as a string is the only way to include it in the serialized JSON.Here's an example of the format used by
time_remaining_str
:"time_remaining": "16 hours 41 minutes 42 seconds",
Line 91: In order to serialize a
Widget
object and preserve the structure whereowner
is aUser
object nested within the parentWidget
object, we use theNested
class in conjunction with thewidget_owner_model
.Here's what the
widget_owner_model
will look like within the parentWidget
object:"owner": { "email": "admin@test.com", "public_id": "475807a4-8497-4c5c-8d70-109b429bb4ef", }
Line 92: The
Widget
class doesn't contain an attribute namedlink
, so what's going on here? I think the best explanation of thefields.Url
class is given in the Flask-RESTx documentation:Flask-RESTx includes a special field,
fields.Url
, that synthesizes a uri for the resource that’s being requested. This is also a good example of how to add data to your response that’s not actually present on your data object.By including a
link
to the URI with eachWidget
, the client can perform CRUD actions without manually constructing or storing the URI (which is an example of HATEOAS). By default, the value returned forlink
will be a relative URI as shown below:"link": "/api/v1/widgets/first_widget",
If the
link
should be an absolute URI (containing scheme, hostname, and port), include the keyword argumentabsolute=True
(e.g.,Url("api.widget", absolute=True)
). In my local test environment, this returns the URI below for the sameWidget
resource:"link": "http://localhost:5000/api/v1/widgets/first_widget",
I hope that all of the material we encountered for the first time in the widget_model
and widget_owner_model
was easy to understand. The remaining API models are much simpler, and contain only a few small bits of new information. If you’re comfortable with all of the information we just covered, let’s move on and finish creating the pagination_model
.
pagination_links_model
and pagination_model
If you go back and look at the pagination example in the Introduction , there’s something important that is included in the example that is not part of the Flask-RESTx Pagination
object. Here’s a hint: it has to do with HATEOAS. The answer is: navigational links:
pagination_links_model = Model(
"Nav Links",
{
"self": String,
"prev": String,
"next": String,
"first": String,
"last": String,
},
)
Since all of the fields in pagination_links_model
are serialized using the String
class, there’s nothing new to discuss in that regard.
Finally, the widget_model
and pagination_links_model
are integrated into the pagination_model
:
|
|
There are only a few things in pagination_model
that we are seeing for the first time:
Line 104: In order to match the object structure shown in the Introduction,
pagination_model
must have a field namedlinks
containing navigational links that allow the client to access allWidget
instances available in the database.Notice that we are using the
Nested
field type the same way we did inwidget_model
, the only difference is that now we are including the keyword argumentskip_none=True
. As explained in the Flask-RESTx documentation, by default, if any of the fields inpagination_links_model
have valueNone
, the JSON output will contain these fields with valuenull
.There are many situations where one or more of the navigational links will be
None
(e.g., for the first page of resultsprev
will always beNone
). By specifyingskip_none=True
, these values will not be rendered in the JSON output, making it much cleaner and reducing the size of the response.Lines 108-110: This isn't new information, I just want to point out that I have renamed these three fields to be more descriptive. IMHO,
total_pages
is a better name thanpages
, and the same thing goes foritems_per_page
/per_page
, as well astotal_items
/total
.Line 111: We have already seen examples using the
Nested
type, which allows us to create complex API models which are composed of built-in types, custom types, and other API models. However, in order to serialize the most important part of the pagination object, we need a way to marshal a list ofwidget
objects to JSON.This is easily accomplished using the
List
type in conjunction with theNested
type. For more information on theList
type, refer to the Flask-RESTx documentation for Response Marshalling.
pagination_model
JSON Example
We finally covered everything necessary to create the pagination_model
API model. Here’s an example of the JSON that our API model will produce given a pagination object with page=2
, per_page=5
, and total=7
:
{
"links": {
"self": "/api/v1/widgets?page=2&per_page=5",
"first": "/api/v1/widgets?page=1&per_page=5",
"prev": "/api/v1/widgets?page=1&per_page=5",
"last": "/api/v1/widgets?page=2&per_page=5"
},
"has_prev": true,
"has_next": false,
"page": 2,
"total_pages": 2,
"items_per_page": 5,
"total_items": 7,
"items": [
{
"name": "foo",
"info_url": "https://www.foo.bar",
"created_at": "11/18/19 06:07:20 AM UTC-08:00",
"created_at_iso8601": "2019-11-18T14:07:20",
"created_at_rfc822": "Mon, 18 Nov 2019 14:07:20 -0000",
"deadline": "11/19/19 11:59:59 PM UTC-08:00",
"deadline_passed": true,
"time_remaining": "No time remaining",
"owner": {
"email": "admin@test.com",
"public_id": "475807a4-8497-4c5c-8d70-109b429bb4ef"
},
"link": "/api/v1/widgets/foo"
},
{
"name": "new-test-555",
"info_url": "http://www.newtest.net",
"created_at": "12/01/19 09:45:26 AM UTC-08:00",
"created_at_iso8601": "2019-12-01T17:45:26",
"created_at_rfc822": "Sun, 01 Dec 2019 17:45:26 -0000",
"deadline": "12/02/19 11:59:59 PM UTC-08:00",
"deadline_passed": false,
"time_remaining": "1 day 14 hours 12 minutes 46 seconds",
"owner": {
"email": "admin@test.com",
"public_id": "475807a4-8497-4c5c-8d70-109b429bb4ef"
},
"link": "/api/v1/widgets/new-test-555"
}
]
}
retrieve_widget_list
Method
Next, we need to create a function that performs the following actions:
- Create a
pagination
object given thepage
andper_page
values parsed from the request data. - Serialize the
pagination
object to JSON usingpagination_model
and theflask_restx.marshal
method. - Construct
dict
of navigational links and add links to response header and body. - Manually construct HTTP response using the
flask.jsonify
method and send response to client.
Before we begin, open src/flask_api_tutorial/api/widgets/business.py
and make the following updates to the import statements:
|
|
Line 4: Update to include the
flask.url_for
method.Line 5: Update to include the
flask_restx.marshal
method.Line 8: Update to include the
token_required
decorator.Line 9: Update to include the
pagination_model
object.
Next, add the content below:
|
|
This code implements the process of responding to a valid request for a list of widgets, please note the following:
Line 31: Per Table 1, the process of retrieving a list of
widgets
can only be performed by registered users (both regular and admin users). This is enforced by decorating theretrieve_widget_list
function with@token_required
.Line 32: The
page
andper_page
parameters are passed toretrieve_widget_list
after thepagination_model
has parsed the values provided by the client from the request data.Line 33: As demonstrated in the Python interactive shell,
pagination
objects are created by callingWidget.query.paginate
, with thepage
andper_page
values provided by the client.Line 34: This is the first time that we are seeing the
flask_restx.marshal
function. However, we already know how it works since we used the@marshall_with
decorator in Part 4.There is only a single, subtle difference between these two functions/decorators. Both operate on an object and filter the object's attributes/keys against the provided API model and validate the object's data against the set of fields configured in the API model.
However,
@marshal_with
operates on the value returned from the function it decorates, whileflask_restx.marshal
operates on whatever is passed to the function as the first parameter. So why are we calling themarshal
function directly? In Lines 37-38 custom header fields are added to the response before it is sent to the client, and there is no way to add these headers using@marshal_with
.Line 35: Remember, the output of the
marshal
function is adict
. Also remember that theflask_sqlalchemy.Pagination
class does not contain navigational links. We will discuss the_pagination_nav_links
function shortly, but what is important to know is that it returns adict
that matches the fields inpagination_links_model
. Thisdict
is then added to thepagination
object with key-namelinks
, which matches the field onpagination_model
containingNested(pagination_links_model, skip_none=True)
.Line 36: After adding the navigational links to the
pagination
object, it is ready to send to the client. We discussed theflask.jsonify
function in Part 3, please review it if you are drawing a blank trying to remember what it does.TL;DR, calling
jsonify(response_data)
convertsresponse_data
(which is adict
object) to JSON and returns aflask.Response
object with the JSON object as the response body.Line 37: Refer back to the
pagination
section, and note that the example includes navigational links in both the JSON response body AND theLink
field in the response header. We will discuss the_pagination_nav_header_links
function very soon, but what is important to know is that it returns a string containing all valid page navigation URLs in the format specified for the Link Header Field defined in RFC 8288.Line 38: This isn't an official best practice, but it is very common to include other metadata about the paginated list in the response header. Here, we create a field named
Total-Count
that contains the total number ofWidget
objects in the database.Line 39: After the response object is fully configured, we return it from the
retrieve_widget_list
function before sending it to the client.Lines 42-58: The
_pagination_nav_links
function accepts a single parameter which is assumed to be aPagination
instance and returns adict
object namednav_links
that matches the fields inpagination_links_model
. By default,nav_links
contains navigation URLs forself
,first
, andlast
pages (even if the total number of pages is one and all navigation URLs are the same).prev
andnext
navigation URLs are not included by default. Ifpagination.has_prev
isTrue
, then theprev
page URL is included (accordingly,next
is included ifpagination.has_next
isTrue
).Lines 61-66: Finally, the
_pagination_nav_header_links
function also accepts a single parameter which is assumed to be aPagination
instance, but instead of adict
object a string is returned containing all valid page navigation URLs in the correct format for theLink
header field.This function calls
_pagination_nav_links
and uses thedict
that is returned to generate theLink
header field. This works because the keys of thedict
object are the same as theLink
field'srel
parameter and thedict
values are the page navigation URLs.
Now that the business logic has been implemented, we can add a method to the api.widget_list
endpoint that will call retrieve_widget_list
after parsing/validating the request data.
api.widget_list
Endpoint (GET Request)
We created the api.widget_list
endpoint in Part 5 and implemented the function that handles POST
requests. According to Table 1, this endpoint also supports GET
requests which allows clients to retrieve lists of widgets
.
Open src/flask_api_tutorial/api/widgets/endpoints.py
and make the following updates to the import statements:
|
|
Line 8: Update to include the
pagination_reqparser
object.Lines 9-12: Update to include the
widget_owner_model
,widget_model
,pagination_links_model
, andpagination_model
API models.Line 14: Update to include the
retrieve_widget_list
function.
Next, add the highlighted lines to endpoints.py
and save the file:
|
|
Lines 17-20: We need to register all of the API models that we created in
app.api.widgets.dto
with thewidget_ns
namespace. This is an easy thing to forget and can be a difficult issue to debug. If the API models are not registered with thewidget_ns
namespace, the entire Swagger UI page will fail to render, displaying only a single cryptic error message: No API definition provided.Line 31: The
response
decorator can be configured with an API model as an optional third argument. This has no effect on the resource's behavior, but the API model is displayed on the Swagger UI page with response code 200 as an example response body.Line 32: The
expect
decorator was explained in depth in Part 3, please review the implementation of the function that handlesPOST
requests for theapi.auth_register
endpoint if you need a refresher. Basically, applying the decorator@widget_ns.expect(create_widget_reqparser)
to a function has two enormous effects: it specifies thatcreate_widget_reqparser
will be used to parse the client's request, AND it renders a form on the Swagger UI page withinput
text elements forwidget.name
,widget.info_url
, andwidget.deadline
.Lines 35-38: Everything here should be completely obvious to you since calling
parse_args
to get the client's request data and passing the data to our business logic is a process that we implement on (nearly) every API handler.
We can verify that the api.widget_list
endpoint now supports both GET
and POST
requests by executing the flask routes
command:
flask-api-tutorial $ flask routes
Endpoint Methods Rule
------------------- --------- --------------------------
api.auth_login POST /api/v1/auth/login
api.auth_logout POST /api/v1/auth/logout
api.auth_register POST /api/v1/auth/register
api.auth_user GET /api/v1/auth/user
api.doc GET /api/v1/ui
api.root GET /api/v1/
api.specs GET /api/v1/swagger.json
api.widget_list GET, POST /api/v1/widgets
restplus_doc.static GET /swaggerui/<path:filename>
static GET /static/<path:filename>
Retrieve Widget
At this point, we have completed two of the five CRUD processes specified in Table 1. Thankfully, the remaining three should be much simpler and faster to implement since many of the elements can be reused (e.g. request parsers, API models).
The two processes we completed (create a widget
, retrieve a list of widgets
) are accessed via the /api/v1/widgets
endpoint, which is also called the resource collection endpoint. However, the remaining three processes are accessed via the resource item endpoint, api/v1/widgets/<name>
. The name
parameter is provided by the client and is used to perform the requested action (retrieve, update, delete) on a specific widget
instance.
Let’s start with the process that is most similar to the last one we implemented, retrieving a single widget
. We do not need to create any request parsers or API models since the name of the widget
is provided in the URI requested by the client, and the widget_model
can be re-used to serialize the requested object in the response.
retrieve_widget
Method
The business logic for retrieving a single widget
is very simple. First, the database is queried for widgets
matching the name
that was requested by the client. If a matching widget
is found, it is serialized to JSON and sent to the client with status code 200 (HTTPStatus.OK
). If no widget
exists with the specified name
, a 404 (HTTPStatus.NOT_FOUND
) response is sent to the client.
Because this is such a common pattern in web applications, Flask-SQLAlchemy provides the first_or_404
method which does exactly what we need. It is modeled after the first
function from SQLAlchemy, which returns either the first result of a query or None
. first_or_404
raises a 404 error instead of returning None
.
Open src/flask_api_tutorial/api/widgets/business.py
and add the function below:
|
|
This operation requires a valid access token, so the @token_required
decorator is applied to the function (Line 42). The first_or_404
method (Line 44) accepts an optional description
parameter which is used to include a message in the body of the HTTP response explaining why the request failed. Also, please note that the name
value provided by the client is converted to lowercase before searching.
api.widget
Endpoint (GET Request)
Next, we need to create the api.widget
endpoint. Before we do so, open src/flask_api_tutorial/api/widgets/endpoints.py
and update the import statements to include the retrieve_widget
function (Line 17):
|
|
Next, add the content below:
|
|
The only thing that we are seeing for the first time is how to include a parameter in the endpoint path. Thankfully, Flask-RESTx uses the same process as Flask for URL route registration (via the @route
decorator). From the Flask documentation:
Variable parts in the route can be specified with angular brackets (
/user/<username>
). By default a variable part in the URL accepts any string without a slash however a different converter can be specified as well by using/<converter:name>
.Variable parts are passed to the view function as keyword arguments.
The following converters are available:
string
accepts any text without a slash (the default) int
accepts integers float
like int but for floating point values path
like the default but also accepts slashes int
matches one of the items provided uuid
accepts UUID strings
For the api.widget
endpoint, the name
parameter is documented in Lines 56-57 and is then passed to the get
method as a parameter in Line 68.
We can verify that the api.widget
endpoint has been registered and currently only responds to GET
requests by executing the flask routes
command:
flask-api-tutorial $ flask routes
Endpoint Methods Rule
------------------- --------- --------------------------
api.auth_login POST /api/v1/auth/login
api.auth_logout POST /api/v1/auth/logout
api.auth_register POST /api/v1/auth/register
api.auth_user GET /api/v1/auth/user
api.doc GET /api/v1/ui
api.root GET /api/v1/
api.specs GET /api/v1/swagger.json
api.widget GET /api/v1/widgets/<name>
api.widget_list GET, POST /api/v1/widgets
restplus_doc.static GET /swaggerui/<path:filename>
static GET /static/<path:filename>
Update Widget
Working our way through Table 1, the next process to implement is updating a single widget
. Clients with administrator access can perform this operation by sending a PUT
request to the api.widget
endpoint.
I’d like you to try and imagine the business logic that the server should perform in this situation. Did you imagine something similar to: retrieve the widget
whose name matches the URI requested by the client, parse the request data containing the updated data/values, and modify the attributes of the widget
using the parsed values?
This is certainly a sensible, common sense answer. And the function that we implement will do the same thing in certain situations. However, RFC 7231 (HTTP/1.1 Semantics and Content) contains the formal specification for the PUT
method. I’ve excerpted some of the most important parts of the spec below:
4.3.4. PUTThe PUT method requests that the state of the target resource be created or replaced with the state defined by the representation enclosed in the request message payload.
...
If the target resource does not have a current representation and the PUT successfully creates one, then the origin server MUST inform the user agent by sending a 201 (Created) response. If the target resource does have a current representation and that representation is successfully modified in accordance with the state of the enclosed representation, then the origin server MUST send either a 200 (OK) or a 204 (No Content) response to indicate successful completion of the request
....
An origin server SHOULD verify that the PUT representation is consistent with any constraints the server has for the target resource that cannot or will not be changed by the PUT.
....
The fundamental difference between the POST and PUT methods is highlighted by the different intent for the enclosed representation. The target resource in a POST request is intended to handle the enclosed representation according to the resource's own semantics, whereas the enclosed representation in a PUT request is defined as replacing the state of the target resource. Hence, the intent of PUT is idempotent and visible to intermediaries, even though the exact effect is only known by the origin server.
I know, the language is highly technical and much more complex than the answer to the hypothetical question I posed. Fear not, implementing the PUT
method per the specification doesn’t require much additional effort.
update_widget_reqparser
Request Parser
The request data sent by the client for a PUT
request is nearly identical to the data sent for a POST
request, with one important difference. With a PUT
request, the name
parameter is provided in the URI instead of the body of the request. Because of this, we can’t just re-use the create_widget_reqparser
as-is to parse a PUT
request.
It turns out that the need to re-use portions of a request parser is such a common occurrence that Flask-RESTx provides methods to copy an existing parser, and then add/remove arguments. This is extremely useful since it obviates the need to re-write every argument and duplicate a bunch of code just to slightly tweak the behavior of a request parser.
For example, open src/flask_api_tutorial/api/widgets/dto.py
, add the lines below, then save the file:
|
|
Now we have exactly the request parser that we need for PUT
requests received at the api.widgets
endpoint. The update_widget_reqparser
is created by simply copying the create_widget_reqparser
and removing the name
argument. We will import and use this when we are ready to implement the put
method handler.
update_widget
Method
Next, we need to create the business logic that implements the PUT
method as specified in RFC 7231.
First, open src/flask_api_tutorial/api/widgets/business.py
and update the import statements to include the widget_name
function from the app.api.widgets.dto
module (Line 9):
|
|
Then copy the update_widget
function below and add it to business.py
:
|
|
Let’s make sure that the update_widget
function correctly implements the PUT
method:
Line 51: The first thing we do is check the database for a
widget
with the name provided by the client.Lines 53-55: If the name provided by the client is found in the database, we save the retrieved
Widget
instance aswidget
. Next, we iterate over the items inwidget_dict
and overwrite the attributes ofwidget
with the values provided by the client. Then, the updatedwidget
is committed to the database.Lines 56-57: Per the specification, if the name provided by the client already exists, and the
widget
was successfully updated using the values parsed from the request data, we can confirm that the request succeeded by sending either a 200 (HTTPStatus.OK
) or 204 (HTTPStatus.NO_CONTENT
) response.Line 60: If we reach this point, it means that the database does not contain a
widget
with the name provided by the client. Before using this value to create a newwidget
, we must validate it with thewidget_name
function we created in theapp.api.widgets.dto
module. If it is valid, this function will return the value we passed in. If it is not valid, aValueError
will be thrown.Line 62: If the name provided by the client is invalid we cannot create a new
widget
. The server rejects the request with a 400 (HTTPStatus.BAD_REQUEST
) response containing an error message detailing why the value provided is not a validwidget
name.Line 63: If the name provided by the client was successfully validated by the
widget_name
function, we need to add it to thewidget_dict
object since thecreate_widget
function expects to receive adict
object containingname
,info_url
, anddeadline
keys.widget_dict["name"] = valid_name
stores the validated name. At this point,widget_dict
is in the format expected by thecreate_widget
function.Line 64: As specified in RFC 7231, if the name provided by the client does not already exist and this
PUT
request successfully creates one, we can confirm that the request succeeded by sending a 201 (HTTPStatus.CREATED
) response.
I believe that the update_widget
function satisfies the specification for the PUT
method as specified in RFC 7231. If you disagree, please let me know in the comments, I would greatly appreciate it if my understanding of the spec is faulty in any way.
api.widget
Endpoint (PUT Request)
Before we can bring everything together and expose the put
method handler for the api.widget
endpoint, open src/flask_api_tutorial/api/widgets/endpoints.py
and update the import statements to include the update_widget_reqparser
we created in app.api.widgets.dto
(Line 8) and the update_widget
function we created in app.api.widgets.business
(Line 19):
|
|
Next, add the highlighted lines to endpoints.py
and save the file:
|
|
We have previously encountered and explained everything in the put
method, so you should be comfortable moving on without explaining the design/implementation. We can verify that the api.widget
endpoint now supports both GET
and PUT
requests by executing the flask routes
command:
flask-api-tutorial $ flask routes
Endpoint Methods Rule
------------------- --------- --------------------------
api.auth_login POST /api/v1/auth/login
api.auth_logout POST /api/v1/auth/logout
api.auth_register POST /api/v1/auth/register
api.auth_user GET /api/v1/auth/user
api.doc GET /api/v1/ui
api.root GET /api/v1/
api.specs GET /api/v1/swagger.json
api.widget GET, PUT /api/v1/widgets/<name>
api.widget_list GET, POST /api/v1/widgets
restplus_doc.static GET /swaggerui/<path:filename>
static GET /static/<path:filename>
Alright, four down, one to go! The only remaining CRUD process we have not yet implemented is deleting a single widget
.
Delete Widget
Implementing the process to delete a single widget
will be simple and very similar to implementing the process to retrieve a single widget
. The only data that is sent by the client is the name
of the widget
, so we do not need to create a request parser for this process. Also, a successful response will not include any data in the response body, so we do not need to create any API models to serialize the response.
delete_widget
Method
Open src/flask_api_tutorial/api/widgets/business.py
and add the content below:
|
|
The delete_widget
function relies on the Flask-SQLAlchemy first_or_404
method which we used previously in the retrieve_widget
function. If the database does not contain a widget
with the name
provided by the client, the request is rejected and a 404 (HTTPStatus.NOT_FOUND
) response is sent. If a widget
was successfully retrieved, however, it is deleted and the changes are committed to the database. Then, a 204 (HTTPStatus.NO_CONTENT
) response is sent with an empty request body.
api.widget
Endpoint (DELETE Request)
I told you this would be easy! Open src/flask_api_tutorial/api/widgets/endpoints.py
and update the import statements to include the delete_widget
function that we just created in app.api.widgets.business
(Line 20):
|
|
Next, add the highlighted lines to endpoints.py
and save the file:
|
|
We have previously encountered and explained everything in the delete
method, so there’s nothing we need to elaborate on. We can verify that the api.widget
endpoint now supports DELETE
, GET
and PUT
requests by executing the flask routes
command:
flask-api-tutorial $ flask routes
Endpoint Methods Rule
------------------- ---------------- --------------------------
api.auth_login POST /api/v1/auth/login
api.auth_logout POST /api/v1/auth/logout
api.auth_register POST /api/v1/auth/register
api.auth_user GET /api/v1/auth/user
api.doc GET /api/v1/ui
api.root GET /api/v1/
api.specs GET /api/v1/swagger.json
api.widget DELETE, GET, PUT /api/v1/widgets/<name>
api.widget_list GET, POST /api/v1/widgets
restplus_doc.static GET /swaggerui/<path:filename>
static GET /static/<path:filename>
We have finally implemented all of the API routes/CRUD processes specified in Table 1, but we actually have no idea if they are working correctly. At the absolute minimum, we need to create unit tests that verify the “happy path” behavior for each CRUD process. We also need unit tests that verify our request parsers are configured correctly, and requests containing invalid data are rejected.
Unit Tests
At this point, I would like you to attempt to create as many unit tests you can think of for the Widget API endpoints/CRUD operations we implemented in this section and the previous section (Part 5). For each, I will provide a few tests to get you started, and demonstrate how the @pytest.mark.parametrize
decorator makes testing multiple values for a single parameter much simpler.
util.py
First, open tests/util.py
and update the import statements to include the datetime.date
module (Line 2), and also add the string values highlighted below:
|
|
Next, add the create_widget
function to util.py
and save the file:
|
|
This function uses the Flask test client to send a POST
request to the api.widget_list
endpoint, which is responsible for creating new widgets. The function requires two parameters: the test_client
and an access_token
. The remaining three parameters are optional since they have default values, and it should be obvious what these three values are used for. widget_name
, info_url
and deadline_str
are the values that will be used for the name
, info_url
and deadline
values of the new widget object.
conftest.py
We also need to update conftest.py
with a new fixture. First, update the import statements to include the ADMIN_EMAIL
value from tests.util
:
|
|
Next, add the admin
test fixture and save the file:
|
|
This fixture creates a new user with administrator privileges, which will be needed to create new widgets (it will also be needed to modify and delete widgets). We have previously used the user
fixture in test cases where a User
object was needed, and the admin
fixture will be used in the same way.
Create Widget
Create a new file named test_create_widget.py
in tests
, enter the content below and save the file:
|
|
This is the first time we are using the @pytest.mark.parameterize
decorator, which is used to parameterize an argument to a test function. In the test_create_widget_valid_name
function, we are parameterizing the widget_name
argument, and the test will be executed three times; once for each value defined for widget_name
. For example, this is the output from pytest if we were to execute just this single test function:
flask-api-tutorial $ pytest tests/test_create_widget.py
================================================= test session starts ==================================================
platform darwin -- Python 3.7.6, pytest-5.3.5, py-1.8.1, pluggy-0.13.1 -- /Users/aaronluna/Projects/flask-api-tutorial/venv/bin/python
cachedir: .pytest_cache
rootdir: /Users/aaronluna/Projects/flask-api-tutorial, inifile: pytest.ini
plugins: clarity-0.3.0a0, black-0.3.8, dotenv-0.4.0, flask-0.15.1, flake8-1.0.4
collected 5 items
tests/test_create_widget.py::FLAKE8 PASSED [ 20%]
tests/test_create_widget.py::BLACK PASSED [ 40%]
tests/test_create_widget.py::test_create_widget_valid_name[abc123] PASSED [ 60%]
tests/test_create_widget.py::test_create_widget_valid_name[widget-name] PASSED [ 80%]
tests/test_create_widget.py::test_create_widget_valid_name[new_widget1] PASSED [100%]
=================================================== warnings summary ===================================================
tests/test_create_widget.py::test_create_widget_valid_name[abc123]
/Users/aaronluna/Projects/flask-api-tutorial/venv/lib/python3.7/site-packages/flask_restx/model.py:12: DeprecationWarning: Using or importing the ABCs from 'collections' instead of from 'collections.abc' is deprecated since Python 3.3,and in 3.9 it will stop working
from collections import OrderedDict, MutableMapping
tests/test_create_widget.py::test_create_widget_valid_name[abc123]
/Users/aaronluna/Projects/flask-api-tutorial/venv/lib/python3.7/site-packages/flask_restx/api.py:28: DeprecationWarning: The import 'werkzeug.cached_property' is deprecated and will be removed in Werkzeug 1.0. Use 'from werkzeug.utils import cached_property' instead.
from werkzeug import cached_property
tests/test_create_widget.py::test_create_widget_valid_name[abc123]
/Users/aaronluna/Projects/flask-api-tutorial/venv/lib/python3.7/site-packages/flask_restx/swagger.py:12: DeprecationWarning: Using or importing the ABCs from 'collections' instead of from 'collections.abc' is deprecated since Python 3.3,and in 3.9 it will stop working
from collections import OrderedDict, Hashable
-- Docs: https://docs.pytest.org/en/latest/warnings.html
============================================ 5 passed, 3 warnings in 0.80s =============================================
You can see the three tests executed by the parameterized test highlighted above. pytest
helpfully includes the values used to parameterize the test in brackets after the name of the test. This function is designed to verify the response to a successful request to create a new widget, so the three values used for parameterization are all valid widget names, per the specs we followed. An obvious next step would be to create a test_create_widget_invalid_name
test case, but I will leave that to you.
Creating valid/invalid values for the info_url
argument should be straightforward, and you should definitely create parameterized test cases for both successful/rejected requests that isolate the info_url
value. However, testing the deadline_str
value is more complex. In the same file, test_create_widget.py
, update the import statements to include the datetime.date
and datetime.timedelta
modules (Line 2), as well as the DEFAULT_NAME
value from tests.util
(Line 7):
|
|
Why is the deadline
attribute more difficult to test than widget_name
or info_url
? Remember, deadline
is a string value that is parsed to a datetime value which must not be in the past. We could use hard-coded values that won’t be in the past 100 years from now, but that is a pretty hacky way to test our code.
What approach can we use that would be better than hard-coded strings? Enter the content below and save the file:
|
|
In the first two highlighted lines above (Lines 27-28), we call the datetime.strftime
method on objects created with datetime.date.today
. This generates a string value that always represents the current date, even if this test case is executed 10,000 years from now. datetime.strftime
accepts a string value that can be configured to generate a string in any format, containing any combination of values such as month, year, hour, time zone, etc.
The third highlighted line (Line 29) utilizes a timedelta
object to generate a date three days in the future (we obviously want to test dates other than the current date). Run pytest tests/test_create_widget.py
to execute these test cases:
flask-api-tutorial $ pytest tests/test_create_widget.py
================================================= test session starts ==================================================
platform darwin -- Python 3.7.6, pytest-5.3.5, py-1.8.1, pluggy-0.13.1 -- /Users/aaronluna/Projects/flask-api-tutorial/venv/bin/python
cachedir: .pytest_cache
rootdir: /Users/aaronluna/Projects/flask-api-tutorial, inifile: pytest.ini
plugins: clarity-0.3.0a0, black-0.3.8, dotenv-0.4.0, flask-0.15.1, flake8-1.0.4
collected 8 items
tests/test_create_widget.py::FLAKE8 PASSED [ 12%]
tests/test_create_widget.py::BLACK PASSED [ 25%]
tests/test_create_widget.py::test_create_widget_valid_name[abc123] PASSED [ 37%]
tests/test_create_widget.py::test_create_widget_valid_name[widget-name] PASSED [ 50%]
tests/test_create_widget.py::test_create_widget_valid_name[new_widget1] PASSED [ 62%]
tests/test_create_widget.py::test_create_widget_valid_deadline[02/29/2020] PASSED [ 75%]
tests/test_create_widget.py::test_create_widget_valid_deadline[2020-02-29] PASSED [ 87%]
tests/test_create_widget.py::test_create_widget_valid_deadline[Mar 03 2020] PASSED [100%]
=================================================== warnings summary ===================================================
tests/test_create_widget.py::test_create_widget_valid_name[abc123]
/Users/aaronluna/Projects/flask-api-tutorial/venv/lib/python3.7/site-packages/flask_restx/model.py:12: DeprecationWarning: Using or importing the ABCs from 'collections' instead of from 'collections.abc' is deprecated since Python 3.3,and in 3.9 it will stop working
from collections import OrderedDict, MutableMapping
tests/test_create_widget.py::test_create_widget_valid_name[abc123]
/Users/aaronluna/Projects/flask-api-tutorial/venv/lib/python3.7/site-packages/flask_restx/api.py:28: DeprecationWarning: The import 'werkzeug.cached_property' is deprecated and will be removed in Werkzeug 1.0. Use 'from werkzeug.utils import cached_property' instead.
from werkzeug import cached_property
tests/test_create_widget.py::test_create_widget_valid_name[abc123]
/Users/aaronluna/Projects/flask-api-tutorial/venv/lib/python3.7/site-packages/flask_restx/swagger.py:12: DeprecationWarning: Using or importing the ABCs from 'collections' instead of from 'collections.abc' is deprecated since Python 3.3,and in 3.9 it will stop working
from collections import OrderedDict, Hashable
-- Docs: https://docs.pytest.org/en/latest/warnings.html
============================================ 8 passed, 3 warnings in 1.13s =============================================
We can easily see what values were used to test the deadline
value in the results above. For the first two parameterized values, the current date was converted to string values 01/29/2020
and 2020-01-29
, which succeeded in creating a new widget object in the database. Finally, Feb 01 2020
was used to verify that a date in the future is a valid value for deadline
.
Before we create test cases for rejected requests, update the import statements to include the BAD_REQUEST
value from tests.util
(Line 7):
|
|
Then, add the test_create_widget_invalid_deadline
function and save the file:
|
|
Unlike the parameters in the “happy” path test case, we can use hardcoded strings to verify that invalid deadline
values are rejected by the API. The first parameterized value is 1/1/1970
*(Line 48), which will be parsed as a valid datetime
value by dateutil.parser
, but must be rejected since it has already passed.
Next, we construct a string value for a date three days in the past using a timedelta
object (Line 49). This is the same technique we used in the “happy” path to construct dates in the future. The third parameter is a string that is not a formatted datetime
value, and should obviously be rejected by datetuil.parser
(Line 50).
Run pytest tests/test_create_widget.py
to execute these test cases:
flask-api-tutorial $ pytest tests/test_create_widget.py
================================================= test session starts ==================================================
platform darwin -- Python 3.7.6, pytest-5.3.5, py-1.8.1, pluggy-0.13.1 -- /Users/aaronluna/Projects/flask-api-tutorial/venv/bin/python
cachedir: .pytest_cache
rootdir: /Users/aaronluna/Projects/flask-api-tutorial, inifile: pytest.ini
plugins: clarity-0.3.0a0, black-0.3.8, dotenv-0.4.0, flask-0.15.1, flake8-1.0.4
collected 11 items
tests/test_create_widget.py::FLAKE8 PASSED [ 9%]
tests/test_create_widget.py::BLACK PASSED [ 18%]
tests/test_create_widget.py::test_create_widget_valid_name[abc123] PASSED [ 27%]
tests/test_create_widget.py::test_create_widget_valid_name[widget-name] PASSED [ 36%]
tests/test_create_widget.py::test_create_widget_valid_name[new_widget1] PASSED [ 45%]
tests/test_create_widget.py::test_create_widget_valid_deadline[02/29/2020] PASSED [ 54%]
tests/test_create_widget.py::test_create_widget_valid_deadline[2020-02-29] PASSED [ 63%]
tests/test_create_widget.py::test_create_widget_valid_deadline[Mar 03 2020] PASSED [ 72%]
tests/test_create_widget.py::test_create_widget_invalid_deadline[1/1/1970] PASSED [ 81%]
tests/test_create_widget.py::test_create_widget_invalid_deadline[2020-02-26] PASSED [ 90%]
tests/test_create_widget.py::test_create_widget_invalid_deadline[a long time ago, in a galaxy far, far away] PASSED [100%]
=================================================== warnings summary ===================================================
tests/test_create_widget.py::test_create_widget_valid_name[abc123]
/Users/aaronluna/Projects/flask-api-tutorial/venv/lib/python3.7/site-packages/flask_restx/model.py:12: DeprecationWarning: Using or importing the ABCs from 'collections' instead of from 'collections.abc' is deprecated since Python 3.3,and in 3.9 it will stop working
from collections import OrderedDict, MutableMapping
tests/test_create_widget.py::test_create_widget_valid_name[abc123]
/Users/aaronluna/Projects/flask-api-tutorial/venv/lib/python3.7/site-packages/flask_restx/api.py:28: DeprecationWarning: The import 'werkzeug.cached_property' is deprecated and will be removed in Werkzeug 1.0. Use 'from werkzeug.utils import cached_property' instead.
from werkzeug import cached_property
tests/test_create_widget.py::test_create_widget_valid_name[abc123]
/Users/aaronluna/Projects/flask-api-tutorial/venv/lib/python3.7/site-packages/flask_restx/swagger.py:12: DeprecationWarning: Using or importing the ABCs from 'collections' instead of from 'collections.abc' is deprecated since Python 3.3,and in 3.9 it will stop working
from collections import OrderedDict, Hashable
-- Docs: https://docs.pytest.org/en/latest/warnings.html
============================================ 11 passed, 3 warnings in 1.18s ============================================
Hopefully, these three parameterized test cases are illustrative and enable you to create the remaining necessary test cases for the widget_name
and info_url
attributes.
There are plenty of other requirements and expected behaviors that need test coverage other than the input validation performed on the client’s request data. I’ll show you two more test cases for the create widget operation, but you must attempt to create other test cases on your own for any remaining functionality.
First, we should verify that it isn’t possible to create a widget if a widget with the same name has already been created. We can easily write a test case to check this scenario. Add the test case below (test_create_widget_already_exists
) to test_create_widget.py
and save the file:
|
|
The main thing that we need to verify is that the HTTP response code from the server is 409 (HTTPStatus.CONFLICT
) (Line 70), indicating that the new widget was not created due to a conflict with an existing widget. This is also verified in the error message sent in the server’s response (Lines 72-73).
So far, in every test case for the create widget process we used the admin user account. However, we absolutely must verify that users without administrator access cannot create new widgets. Before we begin, we need to update the import statements to include the EMAIL
(Line 8) and FORBIDDEN
(Line 11) string values from tests.util
:
|
|
Next, add the test case below (test_create_widget_no_admin_token
) to test_create_widget.py
and save the file:
|
|
As in the previous test case, the main thing we need to verify is that the HTTP status code of the response is 403 (HTTPStatus.FORBIDDEN
) (Line 89), and that the error message indicates that request was rejected since the user does not have administrator privileges (Line 91).
Let’s verify that all of our test cases are still passing by running tox
:
(flask-api-tutorial) flask-api-tutorial $ tox
GLOB sdist-make: /Users/aaronluna/Projects/flask-api-tutorial/setup.py
py37 create: /Users/aaronluna/Projects/flask-api-tutorial/.tox/py37
py37 installdeps: black, flake8, pydocstyle, pytest, pytest-black, pytest-clarity, pytest-dotenv, pytest-flake8, pytest-flask
py37 inst: /Users/aaronluna/Projects/flask-api-tutorial/.tox/.tmp/package/1/flask-api-tutorial-0.1.zip
py37 installed: alembic==1.4.0,aniso8601==8.0.0,appdirs==1.4.3,attrs==19.3.0,bcrypt==3.1.7,black==19.10b0,certifi==2019.11.28,cffi==1.14.0,chardet==3.0.4,Click==7.0,entrypoints==0.3,flake8==3.7.9,Flask==1.1.1,flask-api-tutorial==0.1,Flask-Bcrypt==0.7.1,Flask-Cors==3.0.8,Flask-Migrate==2.5.2,flask-restx==0.1.1,Flask-SQLAlchemy==2.4.1,idna==2.9,importlib-metadata==1.5.0,itsdangerous==1.1.0,Jinja2==2.11.1,jsonschema==3.2.0,Mako==1.1.1,MarkupSafe==1.1.1,mccabe==0.6.1,more-itertools==8.2.0,packaging==20.1,pathspec==0.7.0,pluggy==0.13.1,py==1.8.1,pycodestyle==2.5.0,pycparser==2.19,pydocstyle==5.0.2,pyflakes==2.1.1,PyJWT==1.7.1,pyparsing==2.4.6,pyrsistent==0.15.7,pytest==5.3.5,pytest-black==0.3.8,pytest-clarity==0.3.0a0,pytest-dotenv==0.4.0,pytest-flake8==1.0.4,pytest-flask==0.15.1,python-dateutil==2.8.1,python-dotenv==0.12.0,python-editor==1.0.4,pytz==2019.3,regex==2020.2.20,requests==2.23.0,six==1.14.0,snowballstemmer==2.0.0,SQLAlchemy==1.3.13,termcolor==1.1.0,toml==0.10.0,typed-ast==1.4.1,urllib3==1.25.8,wcwidth==0.1.8,Werkzeug==0.16.1,zipp==3.0.0
py37 run-test-pre: PYTHONHASHSEED='2677345098'
py37 run-test: commands[0] | pytest
================================================= test session starts ==================================================
platform darwin -- Python 3.7.6, pytest-5.3.5, py-1.8.1, pluggy-0.13.1 -- /Users/aaronluna/Projects/flask-api-tutorial/.tox/py37/bin/python
cachedir: .tox/py37/.pytest_cache
rootdir: /Users/aaronluna/Projects/flask-api-tutorial, inifile: pytest.ini
plugins: clarity-0.3.0a0, black-0.3.8, dotenv-0.4.0, flask-0.15.1, flake8-1.0.4
collected 92 items
run.py::FLAKE8 PASSED [ 1%]
run.py::BLACK PASSED [ 2%]
setup.py::FLAKE8 PASSED [ 3%]
setup.py::BLACK PASSED [ 4%]
src/flask_api_tutorial/__init__.py::FLAKE8 PASSED [ 5%]
src/flask_api_tutorial/__init__.py::BLACK PASSED [ 6%]
src/flask_api_tutorial/config.py::FLAKE8 PASSED [ 7%]
src/flask_api_tutorial/config.py::BLACK PASSED [ 8%]
src/flask_api_tutorial/api/__init__.py::FLAKE8 PASSED [ 9%]
src/flask_api_tutorial/api/__init__.py::BLACK PASSED [ 10%]
src/flask_api_tutorial/api/exceptions.py::FLAKE8 PASSED [ 11%]
src/flask_api_tutorial/api/exceptions.py::BLACK PASSED [ 13%]
src/flask_api_tutorial/api/auth/__init__.py::FLAKE8 PASSED [ 14%]
src/flask_api_tutorial/api/auth/__init__.py::BLACK PASSED [ 15%]
src/flask_api_tutorial/api/auth/business.py::FLAKE8 PASSED [ 16%]
src/flask_api_tutorial/api/auth/business.py::BLACK PASSED [ 17%]
src/flask_api_tutorial/api/auth/decorators.py::FLAKE8 PASSED [ 18%]
src/flask_api_tutorial/api/auth/decorators.py::BLACK PASSED [ 19%]
src/flask_api_tutorial/api/auth/dto.py::FLAKE8 PASSED [ 20%]
src/flask_api_tutorial/api/auth/dto.py::BLACK PASSED [ 21%]
src/flask_api_tutorial/api/auth/endpoints.py::FLAKE8 PASSED [ 22%]
src/flask_api_tutorial/api/auth/endpoints.py::BLACK PASSED [ 23%]
src/flask_api_tutorial/api/widgets/__init__.py::FLAKE8 PASSED [ 25%]
src/flask_api_tutorial/api/widgets/__init__.py::BLACK PASSED [ 26%]
src/flask_api_tutorial/api/widgets/business.py::FLAKE8 PASSED [ 27%]
src/flask_api_tutorial/api/widgets/business.py::BLACK PASSED [ 28%]
src/flask_api_tutorial/api/widgets/dto.py::FLAKE8 PASSED [ 29%]
src/flask_api_tutorial/api/widgets/dto.py::BLACK PASSED [ 30%]
src/flask_api_tutorial/api/widgets/endpoints.py::FLAKE8 PASSED [ 31%]
src/flask_api_tutorial/api/widgets/endpoints.py::BLACK PASSED [ 32%]
src/flask_api_tutorial/models/__init__.py::FLAKE8 PASSED [ 33%]
src/flask_api_tutorial/models/__init__.py::BLACK PASSED [ 34%]
src/flask_api_tutorial/models/token_blacklist.py::FLAKE8 PASSED [ 35%]
src/flask_api_tutorial/models/token_blacklist.py::BLACK PASSED [ 36%]
src/flask_api_tutorial/models/user.py::FLAKE8 PASSED [ 38%]
src/flask_api_tutorial/models/user.py::BLACK PASSED [ 39%]
src/flask_api_tutorial/models/widget.py::FLAKE8 PASSED [ 40%]
src/flask_api_tutorial/models/widget.py::BLACK PASSED [ 41%]
src/flask_api_tutorial/util/__init__.py::FLAKE8 PASSED [ 42%]
src/flask_api_tutorial/util/__init__.py::BLACK PASSED [ 43%]
src/flask_api_tutorial/util/datetime_util.py::FLAKE8 PASSED [ 44%]
src/flask_api_tutorial/util/datetime_util.py::BLACK PASSED [ 45%]
src/flask_api_tutorial/util/result.py::FLAKE8 PASSED [ 46%]
src/flask_api_tutorial/util/result.py::BLACK PASSED [ 47%]
tests/__init__.py::FLAKE8 PASSED [ 48%]
tests/__init__.py::BLACK PASSED [ 50%]
tests/conftest.py::FLAKE8 PASSED [ 51%]
tests/conftest.py::BLACK PASSED [ 52%]
tests/test_auth_login.py::FLAKE8 PASSED [ 53%]
tests/test_auth_login.py::BLACK PASSED [ 54%]
tests/test_auth_login.py::test_login PASSED [ 55%]
tests/test_auth_login.py::test_login_email_does_not_exist PASSED [ 56%]
tests/test_auth_logout.py::FLAKE8 PASSED [ 57%]
tests/test_auth_logout.py::BLACK PASSED [ 58%]
tests/test_auth_logout.py::test_logout PASSED [ 59%]
tests/test_auth_logout.py::test_logout_token_blacklisted PASSED [ 60%]
tests/test_auth_register.py::FLAKE8 PASSED [ 61%]
tests/test_auth_register.py::BLACK PASSED [ 63%]
tests/test_auth_register.py::test_auth_register PASSED [ 64%]
tests/test_auth_register.py::test_auth_register_email_already_registered PASSED [ 65%]
tests/test_auth_register.py::test_auth_register_invalid_email PASSED [ 66%]
tests/test_auth_user.py::FLAKE8 PASSED [ 67%]
tests/test_auth_user.py::BLACK PASSED [ 68%]
tests/test_auth_user.py::test_auth_user PASSED [ 69%]
tests/test_auth_user.py::test_auth_user_no_token PASSED [ 70%]
tests/test_auth_user.py::test_auth_user_expired_token PASSED [ 71%]
tests/test_config.py::FLAKE8 PASSED [ 72%]
tests/test_config.py::BLACK PASSED [ 73%]
tests/test_config.py::test_config_development PASSED [ 75%]
tests/test_config.py::test_config_testing PASSED [ 76%]
tests/test_config.py::test_config_production PASSED [ 77%]
tests/test_create_widget.py::FLAKE8 PASSED [ 78%]
tests/test_create_widget.py::BLACK PASSED [ 79%]
tests/test_create_widget.py::test_create_widget_valid_name[abc123] PASSED [ 80%]
tests/test_create_widget.py::test_create_widget_valid_name[widget-name] PASSED [ 81%]
tests/test_create_widget.py::test_create_widget_valid_name[new_widget1] PASSED [ 82%]
tests/test_create_widget.py::test_create_widget_valid_deadline[02/29/2020] PASSED [ 83%]
tests/test_create_widget.py::test_create_widget_valid_deadline[2020-02-29] PASSED [ 84%]
tests/test_create_widget.py::test_create_widget_valid_deadline[Mar 03 2020] PASSED [ 85%]
tests/test_create_widget.py::test_create_widget_invalid_deadline[1/1/1970] PASSED [ 86%]
tests/test_create_widget.py::test_create_widget_invalid_deadline[2020-02-26] PASSED [ 88%]
tests/test_create_widget.py::test_create_widget_invalid_deadline[a long time ago, in a galaxy far, far away] PASSED [ 89%]
tests/test_create_widget.py::test_create_widget_already_exists PASSED [ 90%]
tests/test_create_widget.py::test_create_widget_no_admin_token PASSED [ 91%]
tests/test_user.py::FLAKE8 PASSED [ 92%]
tests/test_user.py::BLACK PASSED [ 93%]
tests/test_user.py::test_encode_access_token PASSED [ 94%]
tests/test_user.py::test_decode_access_token_success PASSED [ 95%]
tests/test_user.py::test_decode_access_token_expired PASSED [ 96%]
tests/test_user.py::test_decode_access_token_invalid PASSED [ 97%]
tests/util.py::FLAKE8 PASSED [ 98%]
tests/util.py::BLACK PASSED [100%]
=================================================== warnings summary ===================================================
src/flask_api_tutorial/api/exceptions.py::FLAKE8
/Users/aaronluna/Projects/flask-api-tutorial/.tox/py37/lib/python3.7/site-packages/flask_restx/model.py:12: DeprecationWarning: Using or importing the ABCs from 'collections' instead of from 'collections.abc' is deprecated since Python 3.3,and in 3.9 it will stop working
from collections import OrderedDict, MutableMapping
src/flask_api_tutorial/api/exceptions.py::FLAKE8
/Users/aaronluna/Projects/flask-api-tutorial/.tox/py37/lib/python3.7/site-packages/flask_restx/api.py:28: DeprecationWarning: The import 'werkzeug.cached_property' is deprecated and will be removed in Werkzeug 1.0. Use 'from werkzeug.utils import cached_property' instead.
from werkzeug import cached_property
src/flask_api_tutorial/api/exceptions.py::FLAKE8
/Users/aaronluna/Projects/flask-api-tutorial/.tox/py37/lib/python3.7/site-packages/flask_restx/swagger.py:12: DeprecationWarning: Using or importing the ABCs from 'collections' instead of from 'collections.abc' is deprecated since Python 3.3,and in 3.9 it will stop working
from collections import OrderedDict, Hashable
-- Docs: https://docs.pytest.org/en/latest/warnings.html
=========================================== 92 passed, 3 warnings in 25.40s ============================================
_______________________________________________________ summary ________________________________________________________
py37: commands succeeded
congratulations :)
Please do not assume that these test cases are sufficient for the create widget operation, there are many requirements that have not been verified by the test cases I provided. You absolutely must attempt to identify these gaps and create all necessary test cases.
Retrieve Widget List
In order to test the retrieve widget list operation, we need to create a function that uses the Flask test client to send a GET
request to the api.widget_list
endpoint. Remember, this endpoint expects the client’s request to include pagination query parameters (i.e., page
and per_page
), refer back to this section if you need to review how this was implemented.
Open tests/util.py
, add the content below and save the file:
|
|
Hopefully the retrieve_widget_list
function above makes sense to you since it is very similar (and much simpler than) the create_widget
function we used to test the create widget operation.
I’m only going to provide a single test case for the retrieve widget list operation. Before you get all mopey, this test case is quite thorough. In fact, it is so thorough, I had to add comments explaining what is being tested and verified. Create a new file named test_retrieve_widget_list.py
in the tests
folder and add the content below:
|
|
Wow, that test case is a doozy! Let’s take a look at what exactly is being tested since there’s a lot going on here:
Lines 8-36: These three lists contain the data required to create seven widget instances. Each item in each list is unique, in order to create test data that is as realistic as possible. This also makes the verifications that we perform more effective since we prevent any "false positive" results from occurring.
Lines 45-53: The seven widget instances are added to the database. Why seven? When we created the
pagination_reqparser
, we defined that the only valid values for theper_page
parameter are[5, 10, 25, 50, 100]
. Creating seven widget instances and requesting a paginated list withper_page=5
allows us to verify that our pagination logic is working correctly since the expected result is that the seven widget instances will generate two pages: the first with a total of five widgets, and the second with a total of two widgets.Lines 56-57: We use the
retrieve_widget_list
function that we created intests.util
to send a request for the first page of widgets with 5 items per page (page=1, per_page=5
), and verify that the HTTP status code of the response is 200HTTPStatus.OK
.Lines 60-65: Since the request was sucessful, we know that
response.json
contains the pagination object. If you need to remind yourself how this object is structured, click here.We verify that the pagination object correctly represents the first page of two total pages of widgets containing five widgets per page. For example,
has_prev
should beFalse
,has_next
should beTrue
,page
should be equal to1
, etc.Line 66: Next, we verify that
items
(which is the list of widgets on page 1) contains five elements.Lines 69-75: After verifying the pagination attributes, we verify that the widgets retrieved from the database contain the exact values for
widget_name
,info_url
anddeadline_str
that were taken from theNAMES
,URLS
andDEADLINES
lists when we created the first five widget instances and added them to the database. This is done in a simple, compact way with afor
loop.Lines 78-79: After verifying all data on page 1, we send a request for the second page of widgets with 5 items per page (
page=2, per_page=5
), which should return a response with status code 200 (HTTPStatus.OK
).Lines 82-87: Next, we verify that the pagination object correctly represents the second page of two total pages of widgets containing five widgets per page. For example,
has_prev
should beTrue
,has_next
should beFalse
,page
should be equal to2
, etc.Line 88: Since there are seven total widgets in the database and page 1 contained #1-5, we expect that
items
on page 2 will contain two elements (i.e., widgets #6-7)Lines 91-97: In the same way that we did for page 1, we verify that the widgets retrieved from the database contain the exact values for
widget_name
,info_url
anddeadline_str
that were taken from theNAMES
,URLS
andDEADLINES
lists when we created the final two widget instances and added them to the database.At this point, we have verified that our pagination scheme is working correctly when a client requests a list of widgets with five items per page. Let's see if everything is working as expected with a larger
per_page
value.Lines 100-101: We send a request for the first page of widgets with 10 items per page (
page=1, per_page=10
), and verify that the HTTP status code of the response is 200HTTPStatus.OK
.Lines 104-109: We verify that the pagination object correctly represents the first page of one total page of widgets containing ten widgets per page. For example,
has_prev
should beFalse
,has_next
should beFalse
,page
should be equal to1
, etc.Lines 110: Next, we verify that
items
(which is the list of widgets on page 1) contains seven elements.Lines 113-119: This should look familiar to you by now. We verify that the widgets retrieved from the database contain the exact values for
widget_name
,info_url
anddeadline_str
that were taken from theNAMES
,URLS
andDEADLINES
lists when we created all seven widget instances and added them to the database.Lines 122-141: I'm going to summarize the rest of the test case since it is nearly identical to the
page=1, per_page=10
results that we just explained. When requesting the list of widgets in Line 122, no values are specified forpage
andper_page
. Since we specified that default values ofpage=1
andper_page=10
will be used when the request does not contain either value, we perform the same verifications which we just explained for Lines 103-119.
Are you still with me? I know that this section has been quite tedious but bear with me! The end is in sight, and when we get there you will have a fully-featured REST API with a satisfactory level of test-coverage. Just imagine how excited you will be.
Retrieve Widget
Hopefully you can figure out what we need to do at this point, since we did the same thing for the previous two sets of test cases: create a function that sends a request to the API endpoint responsible for handling the current operation, with the correct HTTP method type. Since we now are testing the retrieve widget operation, we need to use the Flask test client to send a GET
request to the api.widget
endpoint, specifying the name of the widget we wish to retrieve in the URL path.
Open tests/util.py
and add the content below:
|
|
This is very similar to the retrieve_widget_list
function that we created in the same file so it should be easy to understand. Create a new file named test_retrieve_widget.py
in the tests
folder and add everything you see below:
|
|
The first test case, test_retrieve_widget_non_admin_user
, is a basic happy-path scenario with one small wrinkle. This operation requires a valid access token, but does not require administrator privileges. So, while the request to create a new widget is sent by the admin
user (Line 20), the request to retrieve the same widget is sent by the regular non-admin user
(Line 26). After that request succeeds, we verify the attributes of the retrieved widget match the values used to create it.
The second test case, test_retrieve_widget_does_not_exist
, is a very simple unhappy-path scenario. The first four lines (Lines 37-40) are exactly the same as Lines 23-26 in the previous test case. Why is this noteworthy? Because in this case, we are attempting to retrieve a widget with name="some-widget"
before it has been created and added to the database. We expect to receive a response with status code 404 (HTTPStatus.NOT_FOUND
), and an error message explaining that there is no widget with that name in the database (Lines 41-45).
Update Widget
You know the routine by now, open tests/utils.py
, add the update_widget
function and save the file:
|
|
update_widget
is (obviously) the function that we will use to send a PUT
request to the api.widget
endpoint. Next, create a new file named test_update_widget.py
in tests
, enter the content below and save the file:
|
|
This is the normal, happy-path scenario for updating an existing widget. After logging in with an admin user and creating a widget (Lines 19-23), we update the values for info_url
and deadline_str
(Lines 25-30). Next, we send a request to retrieve the same widget from the database (Line 33) so that we can verify that the info_url
and deadline_str
values were successfully updated (Lines 37-38).
Delete Widget
There is only a single, remaining CRUD operation in Table 1 that we need to create test coverage for: delete widget. Without further ado, open tests/utils.py
, add the delete_widget
function and save the file:
|
|
delete_widget
is the function that we will use to send a DELETE
request to the api.widget
endpoint. Next, create a new file named test_delete_widget.py
in tests
, enter the content below and save the file:
|
|
The first test case, test_delete_widget
, is a basic happy-path scenario. The admin user creates a widget, then sends the request to delete the same widget (Lines 17-22). If the delete request was successful, we expect the response to have a status code of 204 (HTTPStatus.NO_CONTENT
) (Line 23). To verify that the widget was deleted in another way, the admin user sends a request to retrieve a widget with name equal to the name of the widget which was deleted (Line 24). In this case, we expect the response to have a status code of 404 (HTTPStatus.NOT_FOUND
) (Line 25).
The second test case, test_delete_widget_no_admin_token
, is a very simple unhappy-path scenario. The first five lines (Lines 29-33) are exactly the same as Lines 17-21 in the previous test case. However, this test case differs because now we login as the non-admin user and send a request to delete the widget which was created previously (Lines 35-38). However, since this operation can only be performed by admin users, we expect the response to have a status code of 403 (HTTPStatus.FORBIDDEN
), and for the response to contain a field named message explaining that the requested operation can only be performed by users with administrator privileges (Lines 39-4`).
Let’s verify that all of our test cases are still passing by running tox
:
(flask-api-tutorial) flask-api-tutorial $ tox
GLOB sdist-make: /Users/aaronluna/Projects/flask-api-tutorial/setup.py
py37 create: /Users/aaronluna/Projects/flask-api-tutorial/.tox/py37
py37 installdeps: black, flake8, pydocstyle, pytest, pytest-black, pytest-clarity, pytest-dotenv, pytest-flake8, pytest-flask
py37 inst: /Users/aaronluna/Projects/flask-api-tutorial/.tox/.tmp/package/1/flask-api-tutorial-0.1.zip
py37 installed: alembic==1.4.0,aniso8601==8.0.0,appdirs==1.4.3,attrs==19.3.0,bcrypt==3.1.7,black==19.10b0,certifi==2019.11.28,cffi==1.14.0,chardet==3.0.4,Click==7.0,entrypoints==0.3,flake8==3.7.9,Flask==1.1.1,flask-api-tutorial==0.1,Flask-Bcrypt==0.7.1,Flask-Cors==3.0.8,Flask-Migrate==2.5.2,flask-restx==0.1.1,Flask-SQLAlchemy==2.4.1,idna==2.9,importlib-metadata==1.5.0,itsdangerous==1.1.0,Jinja2==2.11.1,jsonschema==3.2.0,Mako==1.1.1,MarkupSafe==1.1.1,mccabe==0.6.1,more-itertools==8.2.0,packaging==20.1,pathspec==0.7.0,pluggy==0.13.1,py==1.8.1,pycodestyle==2.5.0,pycparser==2.19,pydocstyle==5.0.2,pyflakes==2.1.1,PyJWT==1.7.1,pyparsing==2.4.6,pyrsistent==0.15.7,pytest==5.3.5,pytest-black==0.3.8,pytest-clarity==0.3.0a0,pytest-dotenv==0.4.0,pytest-flake8==1.0.4,pytest-flask==0.15.1,python-dateutil==2.8.1,python-dotenv==0.12.0,python-editor==1.0.4,pytz==2019.3,regex==2020.2.20,requests==2.23.0,six==1.14.0,snowballstemmer==2.0.0,SQLAlchemy==1.3.13,termcolor==1.1.0,toml==0.10.0,typed-ast==1.4.1,urllib3==1.25.8,wcwidth==0.1.8,Werkzeug==0.16.1,zipp==3.0.0
py37 run-test-pre: PYTHONHASHSEED='4200454201'
py37 run-test: commands[0] | pytest
================================================= test session starts ==================================================
platform darwin -- Python 3.7.6, pytest-5.3.5, py-1.8.1, pluggy-0.13.1 -- /Users/aaronluna/Projects/flask-api-tutorial/.tox/py37/bin/python
cachedir: .tox/py37/.pytest_cache
rootdir: /Users/aaronluna/Projects/flask-api-tutorial, inifile: pytest.ini
plugins: clarity-0.3.0a0, black-0.3.8, dotenv-0.4.0, flask-0.15.1, flake8-1.0.4
collected 106 items
run.py::FLAKE8 PASSED [ 0%]
run.py::BLACK PASSED [ 1%]
setup.py::FLAKE8 PASSED [ 2%]
setup.py::BLACK PASSED [ 3%]
src/flask_api_tutorial/__init__.py::FLAKE8 PASSED [ 4%]
src/flask_api_tutorial/__init__.py::BLACK PASSED [ 5%]
src/flask_api_tutorial/config.py::FLAKE8 PASSED [ 6%]
src/flask_api_tutorial/config.py::BLACK PASSED [ 7%]
src/flask_api_tutorial/api/__init__.py::FLAKE8 PASSED [ 8%]
src/flask_api_tutorial/api/__init__.py::BLACK PASSED [ 9%]
src/flask_api_tutorial/api/exceptions.py::FLAKE8 PASSED [ 10%]
src/flask_api_tutorial/api/exceptions.py::BLACK PASSED [ 11%]
src/flask_api_tutorial/api/auth/__init__.py::FLAKE8 PASSED [ 12%]
src/flask_api_tutorial/api/auth/__init__.py::BLACK PASSED [ 13%]
src/flask_api_tutorial/api/auth/business.py::FLAKE8 PASSED [ 14%]
src/flask_api_tutorial/api/auth/business.py::BLACK PASSED [ 15%]
src/flask_api_tutorial/api/auth/decorators.py::FLAKE8 PASSED [ 16%]
src/flask_api_tutorial/api/auth/decorators.py::BLACK PASSED [ 16%]
src/flask_api_tutorial/api/auth/dto.py::FLAKE8 PASSED [ 17%]
src/flask_api_tutorial/api/auth/dto.py::BLACK PASSED [ 18%]
src/flask_api_tutorial/api/auth/endpoints.py::FLAKE8 PASSED [ 19%]
src/flask_api_tutorial/api/auth/endpoints.py::BLACK PASSED [ 20%]
src/flask_api_tutorial/api/widgets/__init__.py::FLAKE8 PASSED [ 21%]
src/flask_api_tutorial/api/widgets/__init__.py::BLACK PASSED [ 22%]
src/flask_api_tutorial/api/widgets/business.py::FLAKE8 PASSED [ 23%]
src/flask_api_tutorial/api/widgets/business.py::BLACK PASSED [ 24%]
src/flask_api_tutorial/api/widgets/dto.py::FLAKE8 PASSED [ 25%]
src/flask_api_tutorial/api/widgets/dto.py::BLACK PASSED [ 26%]
src/flask_api_tutorial/api/widgets/endpoints.py::FLAKE8 PASSED [ 27%]
src/flask_api_tutorial/api/widgets/endpoints.py::BLACK PASSED [ 28%]
src/flask_api_tutorial/models/__init__.py::FLAKE8 PASSED [ 29%]
src/flask_api_tutorial/models/__init__.py::BLACK PASSED [ 30%]
src/flask_api_tutorial/models/token_blacklist.py::FLAKE8 PASSED [ 31%]
src/flask_api_tutorial/models/token_blacklist.py::BLACK PASSED [ 32%]
src/flask_api_tutorial/models/user.py::FLAKE8 PASSED [ 33%]
src/flask_api_tutorial/models/user.py::BLACK PASSED [ 33%]
src/flask_api_tutorial/models/widget.py::FLAKE8 PASSED [ 34%]
src/flask_api_tutorial/models/widget.py::BLACK PASSED [ 35%]
src/flask_api_tutorial/util/__init__.py::FLAKE8 PASSED [ 36%]
src/flask_api_tutorial/util/__init__.py::BLACK PASSED [ 37%]
src/flask_api_tutorial/util/datetime_util.py::FLAKE8 PASSED [ 38%]
src/flask_api_tutorial/util/datetime_util.py::BLACK PASSED [ 39%]
src/flask_api_tutorial/util/result.py::FLAKE8 PASSED [ 40%]
src/flask_api_tutorial/util/result.py::BLACK PASSED [ 41%]
tests/__init__.py::FLAKE8 PASSED [ 42%]
tests/__init__.py::BLACK PASSED [ 43%]
tests/conftest.py::FLAKE8 PASSED [ 44%]
tests/conftest.py::BLACK PASSED [ 45%]
tests/test_auth_login.py::FLAKE8 PASSED [ 46%]
tests/test_auth_login.py::BLACK PASSED [ 47%]
tests/test_auth_login.py::test_login PASSED [ 48%]
tests/test_auth_login.py::test_login_email_does_not_exist PASSED [ 49%]
tests/test_auth_logout.py::FLAKE8 PASSED [ 50%]
tests/test_auth_logout.py::BLACK PASSED [ 50%]
tests/test_auth_logout.py::test_logout PASSED [ 51%]
tests/test_auth_logout.py::test_logout_token_blacklisted PASSED [ 52%]
tests/test_auth_register.py::FLAKE8 PASSED [ 53%]
tests/test_auth_register.py::BLACK PASSED [ 54%]
tests/test_auth_register.py::test_auth_register PASSED [ 55%]
tests/test_auth_register.py::test_auth_register_email_already_registered PASSED [ 56%]
tests/test_auth_register.py::test_auth_register_invalid_email PASSED [ 57%]
tests/test_auth_user.py::FLAKE8 PASSED [ 58%]
tests/test_auth_user.py::BLACK PASSED [ 59%]
tests/test_auth_user.py::test_auth_user PASSED [ 60%]
tests/test_auth_user.py::test_auth_user_no_token PASSED [ 61%]
tests/test_auth_user.py::test_auth_user_expired_token PASSED [ 62%]
tests/test_config.py::FLAKE8 PASSED [ 63%]
tests/test_config.py::BLACK PASSED [ 64%]
tests/test_config.py::test_config_development PASSED [ 65%]
tests/test_config.py::test_config_testing PASSED [ 66%]
tests/test_config.py::test_config_production PASSED [ 66%]
tests/test_create_widget.py::FLAKE8 PASSED [ 67%]
tests/test_create_widget.py::BLACK PASSED [ 68%]
tests/test_create_widget.py::test_create_widget_valid_name[abc123] PASSED [ 69%]
tests/test_create_widget.py::test_create_widget_valid_name[widget-name] PASSED [ 70%]
tests/test_create_widget.py::test_create_widget_valid_name[new_widget1] PASSED [ 71%]
tests/test_create_widget.py::test_create_widget_valid_deadline[02/29/2020] PASSED [ 72%]
tests/test_create_widget.py::test_create_widget_valid_deadline[2020-02-29] PASSED [ 73%]
tests/test_create_widget.py::test_create_widget_valid_deadline[Mar 03 2020] PASSED [ 74%]
tests/test_create_widget.py::test_create_widget_invalid_deadline[1/1/1970] PASSED [ 75%]
tests/test_create_widget.py::test_create_widget_invalid_deadline[2020-02-26] PASSED [ 76%]
tests/test_create_widget.py::test_create_widget_invalid_deadline[a long time ago, in a galaxy far, far away] PASSED [ 77%]
tests/test_create_widget.py::test_create_widget_already_exists PASSED [ 78%]
tests/test_create_widget.py::test_create_widget_no_admin_token PASSED [ 79%]
tests/test_delete_widget.py::FLAKE8 PASSED [ 80%]
tests/test_delete_widget.py::BLACK PASSED [ 81%]
tests/test_delete_widget.py::test_delete_widget PASSED [ 82%]
tests/test_delete_widget.py::test_delete_widget_no_admin_token PASSED [ 83%]
tests/test_retrieve_widget.py::FLAKE8 PASSED [ 83%]
tests/test_retrieve_widget.py::BLACK PASSED [ 84%]
tests/test_retrieve_widget.py::test_retrieve_widget_non_admin_user PASSED [ 85%]
tests/test_retrieve_widget.py::test_retrieve_widget_does_not_exist PASSED [ 86%]
tests/test_retrieve_widget_list.py::FLAKE8 PASSED [ 87%]
tests/test_retrieve_widget_list.py::BLACK PASSED [ 88%]
tests/test_retrieve_widget_list.py::test_retrieve_paginated_widget_list PASSED [ 89%]
tests/test_update_widget.py::FLAKE8 PASSED [ 90%]
tests/test_update_widget.py::BLACK PASSED [ 91%]
tests/test_update_widget.py::test_update_widget PASSED [ 92%]
tests/test_user.py::FLAKE8 PASSED [ 93%]
tests/test_user.py::BLACK PASSED [ 94%]
tests/test_user.py::test_encode_access_token PASSED [ 95%]
tests/test_user.py::test_decode_access_token_success PASSED [ 96%]
tests/test_user.py::test_decode_access_token_expired PASSED [ 97%]
tests/test_user.py::test_decode_access_token_invalid PASSED [ 98%]
tests/util.py::FLAKE8 PASSED [ 99%]
tests/util.py::BLACK PASSED [100%]
=================================================== warnings summary ===================================================
src/flask_api_tutorial/api/exceptions.py::FLAKE8
/Users/aaronluna/Projects/flask-api-tutorial/.tox/py37/lib/python3.7/site-packages/flask_restx/model.py:12: DeprecationWarning: Using or importing the ABCs from 'collections' instead of from 'collections.abc' is deprecated since Python 3.3,and in 3.9 it will stop working
from collections import OrderedDict, MutableMapping
src/flask_api_tutorial/api/exceptions.py::FLAKE8
/Users/aaronluna/Projects/flask-api-tutorial/.tox/py37/lib/python3.7/site-packages/flask_restx/api.py:28: DeprecationWarning: The import 'werkzeug.cached_property' is deprecated and will be removed in Werkzeug 1.0. Use 'from werkzeug.utils import cached_property' instead.
from werkzeug import cached_property
src/flask_api_tutorial/api/exceptions.py::FLAKE8
/Users/aaronluna/Projects/flask-api-tutorial/.tox/py37/lib/python3.7/site-packages/flask_restx/swagger.py:12: DeprecationWarning: Using or importing the ABCs from 'collections' instead of from 'collections.abc' is deprecated since Python 3.3,and in 3.9 it will stop working
from collections import OrderedDict, Hashable
-- Docs: https://docs.pytest.org/en/latest/warnings.html
=========================================== 106 passed, 3 warnings in 27.42s ===========================================
_______________________________________________________ summary ________________________________________________________
py37: commands succeeded
congratulations :)
As you can see, thanks to pytest
and various plugins, we have a faily robust test suite as well as automated enforcement of our code formatter (black
) and linter (flake8
) for all files inside the src
and tests
folders.
Swagger UI
It’s been a while since we looked at the Swagger UI page, and it has changed considerably due to the API endpoints and models we created in this section. Fire up the development server with the flask run
command and navigate to http://localhost:5000/api/v1/ui. You should see the page shown below:
You should spend some time testing all of the endpoints. If you need a refresher on requesting and retrieiving an access token, and how to use the access token to send a request for a protected resource, refer to this step-by-step explanation in Part 4
Checkpoint
At long last, we have implemented all of the required features for this project. However, that does not mean that our application is finished and ready to be deployed/shared with the world. In fact, the current state of the project is lacking many vital features and tools to make it more maintainable and approachable for users in the open-source community. At this point we will focus on improving the quality of our project by focusing on test coverage, CICD, logging and security hardening.
I am always interested in hearing feedback on the tutorial, I hope you have enjoyed what has been provided so far.
User Management/JWT Authentication
New users can register by providing an email address and password
Existing users can obtain a JWT by providing their email address and password
JWT contains the following claims: time the token was issued, time the token expires, a value that identifies the user, and a flag that indicates if the user has administrator access
JWT is sent in access_token field of HTTP response after successful authentication with email/password
JWTs must expire after 1 hour (in production)
JWT is sent by client in Authorization field of request header
Requests must be rejected if JWT has been modified
Requests must be rejected if JWT is expired
If user logs out, their JWT is immediately invalid/expired
If JWT is expired, user must re-authenticate with email/password to obtain a new JWT
API Resource: Widget List
All users can retrieve a list of all widgets
All users can retrieve individual widgets by name
Users with administrator access can add new widgets to the database
Users with administrator access can edit existing widgets
Users with administrator access can delete widgets from the database
The widget model contains attributes with URL, datetime, timedelta and bool data types, along with normal text fields.
URL and datetime values must be validated before a new widget is added to the database (and when an existing widget is updated).
The widget model contains a "name" attribute which must be a string value containing only lowercase-letters, numbers and the "-" (hyphen character) or "_" (underscore character).
The widget model contains a "deadline" attribute which must be a datetime value where the date component is equal to or greater than the current date. The comparison does not consider the value of the time component when this comparison is performed.
Widget name must be validated before a new widget is added to the database (and when an existing widget is updated).
If input validation fails either when adding a new widget or editing an existing widget, the API response must include error messages indicating the name(s) of the fields that failed validation.