================
Todo Application
================
This is a sample application that allows you to manage Todo items. This
tutorial will walk through creating a serverless web API to create, update,
get, and delete Todos, managing Todos in a database, and adding authorization
with JWT. AWS services covered include AWS Lambda, Amazon API Gateway, Amazon
DynamoDB, AWS CodeBuild, and AWS Systems Manager.
You can find the full source code for this application in our
`samples directory on GitHub `__.
::
$ git clone git://github.com/aws/chalice
$ cd chalice/docs/source/samples/todo-app/code
We'll now walk through the architecture of this application,
how to deploy and use the application, and finally we'll go over
the main components of the application code.
.. note::
This sample application is also available as a `workshop
`__.
The main difference between the sample apps here and the Chalice workshops
is that the workshop is a detailed step by step process for how to create
this application from scratch. You build the app by gradually adding each
feature piece by piece. In the workshop, we first create a REST API with
no authentication or data store. Then we introduce DynamoDB, then JWT
auth, etc. The workshop also shows you how to set up a CI/CD pipeline
to automatically deploy your application whenever you push to your git
repository. It takes several hours to work through all the workshop
material. In this document we review the architecture, the deployment process,
then walk through the main sections of the final version of this
application.
Architecture
============
The main component of this application is a REST API backed by Amazon API
Gateway and AWS Lambda. The rest API lets you manage a Todo list. It lets you
create a new Todo list as well as check off existing Todo items.
In order to see a list of your Todo items, you must first log in. Information
about our users is stored in an Amazon DynamoDB table. The authentication is
done using a builtin authorizer. This lets you define a Lambda function to
perform your custom auth process. For this sample app, we're using JSON Web
Tokens (JWT).
The Todo items are stored in a separate DynamoDB table. Below is
an architecture diagram of our sample app. It shows the API Gateway
REST API, along with a Lambda function for our authorizer, a Lambda function
for our REST API, and two DynamoDB tables.
.. image:: docs/assets/architecture.jpg
:width: 100%
:alt: Architecture diagram
.. _todo-sample-rest-api:
REST API
--------
The REST API supports the following resources:
* GET - ``/todos/`` - Gets a list of all todo items
* POST - ``/todos/`` - Creates a new Todo item
* GET - ``/todos/{id}`` - Gets a specific todo item
* DELETE - ``/todos/{id}`` - Deletes a specific todo item
* PUT - ``/todos/{id}`` - Updates the state of a todo item
A todo item has this schema::
{
"description": {"type": "str"},
"uid": {"type: "str"},
"state": {"type: "str", "enum": ["unstarted", "started", "completed"]},
"metadata": {
"type": "object"
}
}
Deployment
==========
To run and deploy this application, first create a virtual environment
and install the dependencies. Python 3.7 is used for this sample app.
::
$ python3 -m /tmp/venv37
$ . /tmp/venv37/bin/activate
$ pip install ./requirements-dev.txt
$ pip install ./requirements.txt
As part of this application, there are additional resources that
are created that are used by this application, including two DynamoDB
tables as well as an SSM parameter used to store our secret key used
in our JWT auth. To create these resources, you can run::
$ python create-resources.py
This will also update your ``.chalice/config.json`` file with environment
variables containing the name of the DynamoDB tables that were created.
At this point, you can either test the application by running ``chalice
local``. This will start a local HTTP server on port 8000 that emulates API
Gateway so that you can test without having to deploy your application to AWS.
You can also run ``chalice deploy`` to deploy your application to
AWS, which allows you to test on an actual API Gateway REST API::
$ chalice deploy
Creating deployment package.
Creating IAM role: mytodo-dev-api_handler
Creating lambda function: mytodo-dev
Creating IAM role: mytodo-dev-jwt_auth
Creating lambda function: mytodo-dev-jwt_auth
Creating Rest API
Resources deployed:
- Lambda ARN: arn:aws:lambda:us-east-1:12345:function:mytodo-dev
- Lambda ARN: arn:aws:lambda:us-east-1:12345:function:mytodo-dev-jwt_auth
- Rest API URL: https://abcd.execute-api.us-west-2.amazonaws.com/api/
Using the Application
=====================
If you've deployed your application using ``chalice deploy``, you can test
the REST API by making requests to the ``Rest API URL``, shown in the output
of ``chalice deploy``, in our example that would be
``https://abcd.execute-api.us-west-2.amazonaws.com/api/``. If you're using
``chalice local``, you'll make requests to ``http://localhost:8000/``.
Before we can make requests we need to authenticate with the API. In order
to authenticate with the API we need to create user accounts.
A helper script, ``users.py`` is included in the repository to help you
manage users. The first thing we'll need to do is create a user::
$ python users.py --create-user
Username: myusername
Password:
This will create a new entry in our users DynamoDB table.
You can then test that the password verification works by running::
$ python users.py --test-password
Username: myusername
Password:
Password verified.
Once we've created a test user, we can now login by sending a POST
request to the ``/login`` URL::
$ echo '{"username": "myusername", "password": "mypassword"}' | \
http POST https://abcd.execute-api.us-west-2.amazonaws.com/api/login/
{
"token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJteXVzZXJuYW1lIiwiaWF0IjoxNTk1NDU3Njg5LCJuYmYiOjE1OTU0NTc2ODksImp0aSI6IjMxNjc4YzFkLTdkZjEtNGEzOC04YmZiLTllZjZiMGM1YzAyNyJ9.w46RdtzZdk_P0LAh_St3wjsqgh-k-Hp1ykTpbDqad2k",
}
.. note::
We're using the HTTPie command line tool instead of cURL. You can
install this tool by running ``pip install httpie``.
Now whenever we make any requests to our REST API, we need to include
the token value in the output above as the value of our ``Authorization``
header. For example, we can list all of our Todos, which is initially
empty::
$ http https://abcd.execute-api.us-west-2.amazonaws.com/api/todos/ 'Authorization: my.jwt.token'
HTTP/1.1 200 OK
Content-Length: 2
Content-Type: application/json
[]
If you omit the ``Authorization`` header, you'll see this error response::
$ http https://abcd.execute-api.us-west-2.amazonaws.com/api/todos/
HTTP/1.1 401 Unauthorized
Content-Length: 26
Content-Type: application/json
x-amzn-ErrorType: UnauthorizedException
{
"message": "Unauthorized"
}
We can create a new Todo::
$ echo '{"description": "My first Todo", "metadata": {}}' \
| http POST https://abcd.execute-api.us-west-2.amazonaws.com/api/todos/ \
'Authorization: my.jwt.token'
HTTP/1.1 200 OK
Content-Length: 36
Content-Type: application/json
e25643f7-0b18-47d2-b124-4e6713ab527c
Now when we list our Todos, we'll see our new entry we created::
$ http https://abcd.execute-api.us-west-2.amazonaws.com/api/todos/ 'Authorization: my.jwt.token'
HTTP/1.1 200 OK
Content-Length: 136
Content-Type: application/json
[
{
"description": "My first Todo",
"metadata": {},
"state": "unstarted",
"uid": "e25643f7-0b18-47d2-b124-4e6713ab527c",
"username": "myusername"
}
]
We can update our Todo and mark it completed::
$ echo '{"state": "completed"}' | \
http PUT https://abcd.execute-api.us-west-2.amazonaws.com/api/todos/e25643f7-0b18-47d2-b124-4e6713ab527c \
'Authorization: my.jwt.token'
HTTP/1.1 200 OK
Content-Length: 4
Content-Type: application/json
null
And we can now verify that the Todo item shows up as completed::
$ http https://abcd.execute-api.us-west-2.amazonaws.com/api/todos/e25643f7-0b18-47d2-b124-4e6713ab527c \
'Authorization: my.jwt.token'
HTTP/1.1 200 OK
Content-Length: 134
Content-Type: application/json
{
"description": "My first Todo",
"metadata": {},
"state": "completed",
"uid": "e25643f7-0b18-47d2-b124-4e6713ab527c",
"username": "myusername"
}
Code Walkthrough
================
.. _todo-app-rest-api:
Rest API
--------
Below is the code for the five routes defined in the
:ref:`todo-sample-rest-api` section defined in the ``app.py`` file:
.. literalinclude:: code/app.py
:caption: app.py
:linenos:
:lineno-match:
:lines: 67-105
The first thing all of these routes do is extract the current username from the
request. This is done by examining the context associated with the current
request. This will include the ``principalId``, or the current username, which
is discussed in more detail in the :ref:`todo-app-jwt-auth` section below.
Each of these routes then makes a call into the data storage layer,
and either retrieves or updates data in the ``Todo`` DynamoDB table.
This is discussed in the next section on data storage.
The application DB is tracked as a module level variable that is
retrieved through the ``get_app_db()`` function. The name of the
DynamoDB table is provided through the ``APP_TABLE_NAME`` environment
variable, which is specified in your ``.chalice/config.json`` file.
This was automatically filled in for you when you ran the
``create-resources.py`` script.
User input is extracted from both the URL (the ``uid`` associated with
a Todo item is provided as part of the URL) as well as the JSON
request body. A key takeaway from these routes is that there's
minimal logic in the route definitions themselves. They're primarily
about extracting user input and then delegating the heavy lifting to
other objects that are independent of any routing information.
Data Storage
------------
Each route in this sample application app makes a call to the
data storage layer, which is backed by a DynamoDB table. This interface
is defined by the ``TodoDB`` interface, which is defined in the
``chalicelib/db.py`` file:
.. literalinclude:: code/chalicelib/db.py
:caption: chalicelib/db.py
:linenos:
:lineno-match:
:pyobject: TodoDB
There are two different implementations of this interface. The first one,
``InMemoryTodoDB``, is an in-memory implementation of this interface where
all data is stored within the process. The purpose of this implementation
is for testing purposes when you don't want to work with the real DynamoDB
service. This allows you to develop your application locally and test
using ``chalice local``. The other implementation of ``TodoDB`` interface is
``DynamoDBTodo``, which communicates with the actual DynamoDB service
to store and retrieve Todo items. It uses the Table resource of ``boto3``,
created via ``boto3.resource('dynamodb').Table(TABLE_NAME)``. This allows
us to use the `high level querying interface of boto3 `__.
The implementation is shown below.
.. literalinclude:: code/chalicelib/db.py
:caption: chalicelib/db.py
:linenos:
:lineno-match:
:pyobject: DynamoDBTodo
.. _todo-app-jwt-auth:
JWT Authentication
------------------
.. note::
This example is for illustration purposes and does not necessarily
represent best practices. Its intent is to show how custom
authentication can be implemented in a Chalice app.
Our REST API for our Todo items requires that you send an appropriate
``Authorization`` header when making HTTP requests. You can retrieve
a auth token by making a request to the ``/login`` route with your
user name and password. The underlying mechanism used to handle
our auth functionality is through issuing a `JWT `__
when you login.
Users Table
~~~~~~~~~~~
In order to login, we need a way to store and retrieve user information. This
is done through our ``Users`` DynamoDB table. This was created when you ran
the ``create-resoureces.py`` file. Each user record stores their username and
information about their password. We're using PBKDF2 as our key derivation
function for password hashing, which is available in Python's standard library
through the `hashlib.pbkdf2_hmac
`__
function. The parameters needed by ``pbkdf2_hmac`` are stored in each user's
record, including the password hash, salt, number of rounds, and the hash used
for PBKDF2 (sha256 in our example). These user entries were created and stored
in the ``Users`` DynamoDB table when you ran the ``python users.py
--create-user`` command.
You can see the fields for a specific user by using the ``--get-user`` option
to the ``users.py`` script::
$ python users.py --get-user myusername
Entry for user: myusername
hash : sha256
username : myusername
hashed : Hym8Ss6WIArus+aZ6BucZ3sz6Wu5w8Tc3lPUivTuUi4=
salt : rXMPBx8ZriKU3SQTh58BlxQQtpcLHfmITTB2tpRs/sM=
rounds : 100000
Login Flow
~~~~~~~~~~
Below is the code for the ``/login`` route:
.. literalinclude:: code/app.py
:caption: app.py
:linenos:
:lineno-match:
:pyobject: login
In this login view, we first lookup the user record fom our users DB,
and then try to generate a JWT token for this entry. The
``auth.get_jwt_token`` will first verify that the password hash
matches what's stored in our users DB, and then generate a JWT token
for this user as shown in the code below:
.. literalinclude:: code/chalicelib/auth.py
:caption: chalicelib/auth.py
:linenos:
:lineno-match:
:pyobject: get_jwt_token
The call to ``jwt.encode()`` requires a payload and a secret.
This secret is a value that is only known to our application and is
used in our built-in authorizer to verify the JWT is valid.
This secret value is stored as an SSM parameter. A random secret
was automatically generated and stored in SSM for you when running the
``create-resources.py`` script. When we call ``auth.get_jwt_token`` we first
retrieve this value from SSM as shown in the ``get_auth_key()`` function
defined in our ``app.py`` file:
.. literalinclude:: code/app.py
:caption: app.py
:linenos:
:lineno-match:
:pyobject: get_auth_key
Once we've generated a JWT token, we return the token back to the caller.
They must then provide that same token in the ``Authorization`` header
whenever they make API calls to the REST API.
Custom Authorizer
~~~~~~~~~~~~~~~~~
In order to require that a specific route requires proper authorization,
we must first create an authorizer, and then associate it with any routes
that require auth. Chalice supports different types of
:doc:`../../topics/authorizers`, and in this example we're using the
:ref:`builtin-authorizers` type provided by Chalice. This lets us write
our custom authorization logic as part of our Chalice app. To do this,
we decorate our auth function with the ``@app.authorizer`` decorator.
Our custom authorizer logic takes the JWT token (accessible through the
``auth_request.token`` attribute, and verifies the token is valid
using our secret key retrieved via ``get_auth_key()``. The custom
authorizer is shown below:
.. literalinclude:: code/app.py
:caption: app.py
:linenos:
:lineno-match:
:pyobject: jwt_auth
Once we verify that JWT token is valid, we return an ``AuthResponse`` that
specifies what routes the user is allowed to access. In our example, we're
giving them access to all routes, denoted by a ``*``.
Now that we have our authorizer, we can associate with a route by providing
the function as the value of the ``authorizer=`` parameter. We saw this in the
:ref:`todo-app-rest-api` section above. For example, note that the
``@app.route()`` decorator is being provided an ``authorizer`` function:
.. literalinclude:: code/app.py
:caption: app.py
:linenos:
:lineno-match:
:pyobject: list_todos
Cleaning Up
===========
Once you're finished experimenting with this sample app, you can cleanup your
resources by deleting the Chalice app and deleting any additional resources
associated with this app. To do this, first delete your Chalice app::
$ chalice delete
Deleting Rest API: q7dc49grhk
Deleting function: arn:aws:lambda:us-west-w:12345:function:mytodo-dev-jwt_auth
Deleting IAM role: mytodo-dev-jwt_auth
Deleting function: arn:aws:lambda:us-west-w:12345:function:mytodo-dev
Deleting IAM role: mytodo-dev-api_handler
Then to cleanup the remaining resources, rerun the
``create-resources.py`` script with the ``--cleanup`` flag. This will delete
the DynamoDB tables and the SSM parameter, along with any additional resources
created as part of your Chalice app::
$ python create-resources.py --cleanup
Deleting table: todo-app-632a558c-8355-4c2d-a46e-24350f371389
Deleting table: users-app-05b34fa2-1ae6-4d81-95d1-7ced59878a2b
Deleting SSM param: /todo-sample-app/auth-key
Resources deleted. If you haven't already, be sure to run 'chalice delete' to delete your Chalice application.