marcel-dempers f796c4bfc6 updates
2021-11-12 12:38:00 +11:00

12 KiB

Introduction to Python: HTTP (Flask)

So far, we've learnt Python fundamentals, worked with data, files and more importantly, basic data structures like csv and json.

JSON is a very popular format for now only storing, but transporting data in web HTTP applications. Files are important because we may need to read configuration files for our application.

One application could make a web request to ask for customer data from our application. By sending back JSON, the receiving application can easily process the data it gets.

Be sure to checkout Part 2: Files
As well as Part 3: JSON

Python Dev Environment

The same as Part 1, we start with a dockerfile where we declare our version of python.

FROM python:3.9.6-alpine3.13 as dev

WORKDIR /work

Let's build and start our container:

cd python\introduction\part-4.http

docker build --target dev . -t python
docker run -it -v ${PWD}:/work python sh

/work # python --version
Python 3.9.6

Our application

We're going to use what we've learnt in part 1,2 & 3 and create our customer app that stores customer data
Firstly we have to import our dependencies:

import os.path
import csv
import json

Then we have a class to define what a customer looks like:

class Customer:
  def __init__(self, c="",f="",l=""):
    self.customerID = c
    self.firstName  = f
    self.lastName   = l
  def fullName(self):
    return self.firstName + " " + self.lastName

Then we need a function which returns our customers:

def getCustomers():
  if os.path.isfile("customers.json"):
    with open('customers.json', newline='') as customerFile:
      data = customerFile.read()
      customers = json.loads(data)
      return customers
  else: 
    return {}

Here is a function to return a specific customer:

def getCustomer(customerID):
  customer = getCustomers()
  return customer[customerID]

And finally a function for updating our customers:

def updateCustomers(customers):
  with open('customers.json', 'w', newline='') as customerFile:
    customerJSON = json.dumps(customers)
    customerFile.write(customerJSON)

In the previous episode, we've created a json file to hold all our customers.
We've learnt how to read and write to file and temporarily use the file for our storage.

{
  "a": {
    "customerID": "a",
    "firstName": "James",
    "lastName": "Baker"
  },
  "b": {
    "customerID": "b",
    "firstName": "Jonathan",
    "lastName": "D"
  },
  "c": {
    "customerID": "c",
    "firstName": "Aleem",
    "lastName": "Janmohamed"
  },
  "d": {
    "customerID": "d",
    "firstName": "Ivo",
    "lastName": "Galic"
  },
  "e": {
    "customerID": "e",
    "firstName": "Joel",
    "lastName": "Griffiths"
  },
  "f": {
    "customerID": "f",
    "firstName": "Michael",
    "lastName": "Spinks"
  },
  "g": {
    "customerID": "g",
    "firstName": "Victor",
    "lastName": "Savkov"
  }
}

Now that we have our customer data, we can read and update the data with our two simple functions:

customers = getCustomers()
print(customers)

Let's add a customer and write it back to file:

customers["h"] = Customer("h", "Marcel", "Dempers").__dict__
updateCustomers(customers)

Now customers.json has the new entry if we rerun our code:

python src/app.py

Flask

Checkout official Flask documentation here

A minimal flask application looks like this:

from flask import Flask

app = Flask(__name__)

@app.route("/")
def hello_world():
    return "<p>Hello, World!</p>"

To get this to work, we need to look at Pythons Library system, or pip

External Packages

If we look at the from flask import Flask statement, it assumes we have a flask dependency. Our previous import statements worked, because they are built-in libraries in python.
Flask is an external package.
External packages are managed by pip

We may use pip to install Flask. If we need to connect to databases, we may install packages to do so. Like a mysql or a redis package.

With pip, we define our dependencies in a requirements.txt file. We can check for the latest version of Flask here

We can also use pip install commands to install dependencies.
However, this means we need a new pip install command for every dependency which will grow as our application and needs grow. Best is to use a requirements.txt file.

Let's create a requirements.txt file:

Flask == 2.0.1

We can install our dependencies using:

pip install -r src/requirements.txt

Let's implement the minimal example we posted above.
To run our application, note that the app.py runs as per usual.
But to start Flask, we can follow the instructions as per the official document:

export FLASK_APP=src/app
flask run -h 0.0.0 -p 5000

Note that we cannot access http://localhost:5000 because our app is running in docker.
To access it, we need to restart our container and this time expose our port.

exit
docker run -it -p 5000:5000 -v ${PWD}:/work python sh

#get our dependencies and start our application
pip install -r requirements.txt
export FLASK_APP=src/app
flask run -h 0.0.0 -p 5000

Now we can access our app on http://localhost:5000/

Routes

Web servers allow us to define various URLs or routes.
Currently we can see logs showing GET requests for route /
Notice the HTTP status code 200 indicating success.

 * Running on http://0.0.0:5000/ (Press CTRL+C to quit)
172.17.0.1 - - [22/Sep/2021 23:10:14] "GET / HTTP/1.1" 200 -
172.17.0.1 - - [22/Sep/2021 23:10:14] "GET /favicon.ico HTTP/1.1" 404 -
172.17.0.1 - - [22/Sep/2021 23:12:20] "GET / HTTP/1.1" 200 -
172.17.0.1 - - [22/Sep/2021 23:12:21] "GET / HTTP/1.1" 200 -
172.17.0.1 - - [22/Sep/2021 23:12:21] "GET / HTTP/1.1" 200 -

Routes allow us to have many endpoints, so we could have endpoints such as:

/all            --> which returns all customers
/get/<customerID>    --> which returns one customer by CustomerID
/add         --> which adds or updates a customer

Defining these routes are pretty straight forward, see docs

Let's define our routes:


@app.route("/")
def get_customers():
    return "<p>Hello, get_customers!</p>"

@app.route("/get/<string:customerID>", methods=['GET'])
def get_customer(customerID):
    return "<p>Hello, get!</p>"

@app.route("/add", methods=['POST'])
def add_customer(customer):
    return "<p>Hello, add!</p>"

Now that we've built our URLs using routes, next we need to understand HTTP methods

HTTP Methods

There are a number of HTTP methods for web services, popular ones being POST and GET

GET method is for general retrieval of data
POST method is for passing data to our service

Let's setup each of our routes with dedicated HTTP Methods

@app.route("/", methods=['GET'])
def get_customers():
    return "<p>Hello, get_customers!</p>"

@app.route("/get/<string:customerID>", methods=['GET'])
def get_customer(customerID):
    return "<p>Hello, get!</p>"

@app.route("/add", methods=['POST'])
def add_customer():
    return "<p>Hello, add!</p>"

Now we can see if we access http://localhost:5000/add We get: Method Not Allowed , because it only accepts POST and browser by default, does GET

Let's fill out our routes

Get Customers

@app.route("/", methods=['GET'])
def get_customers():
    customers = getCustomers()
    return json.dumps(customers)

Get Customer

@app.route("/get/<string:customerID>", methods=['GET'])
def get_customer(customerID):
     customer = getCustomer(customerID)
     return customer

HTTP Status Codes

You can access your browser's developer tools and see the network tab
Status codes are important for troubleshooting production traffic.

200 = Success.

We can then access a customer via the /get/<customerID> route. Our Customer ID's are a to h and notice we get an Internal Server Error if we pass an incorrect ID.

500 = Internal Server Error
This means something went wrong in our code. See the logs indicating a KeyError, because we're trying to access a key in our customer dictionary that does not exist.

Let's handle that error

#update our getCustomer function to check if the key exists

def getCustomer(customerID):
  customers = getCustomers()
  if customerID in customers:
    return customers[customerID]
  else: 
    return {} 

Note when we rerun this, we get a blank customer back.
Also note we're getting a 200 status code.
This may not be super appropriate, as our customer we requested is technically not found. There is a status code for that

404 = Not Found

Let's handle the status code.

@app.route("/get/<string:customerID>", methods=['GET'])
def get_customer(customerID):
    customer = getCustomer(customerID)
    if customer == {}:
      return {}, 404
    else:
      return customer

Add Customer

In order to add a customer, we need to be able to read request data.
More about Requests here

Import requests library

from flask import request

Read the request body as JSON:

@app.route("/add", methods=['POST'])
def add_customer():
    print(request.json)
    return "success", 200

Let's test that!

curl -X POST http://localhost:5000/add -d '{ "data" : "hello" }'

Notice our print message is None, this is because we need to specify content type in the request header. Let's send the content type as JSON.

curl -X POST \
-H 'Content-Type: application/json' \
http://localhost:5000/add -d '{ "data" : "hello" }'

Now we see that we read the data as {'data': 'hello'}
This allows us to read customer data as JSON, validate it, and write it to storage.

Let's POST a customer:

curl -X POST -v \
-H 'Content-Type: application/json' \
http://localhost:5000/add -d '
{
  "customerID": "i",
  "firstName": "Bob",
  "lastName": "Smith"
}'

Note if we break the json format, we get a 400 status code

400 = Bad Request

400 indicates a bad request, letting the client know they have to send the right formatted data.

Docker

Let's build our container image and run it while mounting our customer file

Our final dockerfile

FROM python:3.9.6-alpine3.13 as dev

WORKDIR /work

FROM dev as runtime
WORKDIR /app
COPY requirements.txt /app/
RUN pip install -r /app/requirements.txt

COPY ./src/ /app 
ENV FLASK_APP=app.py

CMD flask run -h 0.0.0 -p 5000

Build our container.

cd python\introduction\part-4.http

docker build . -t customer-app

Notice that we need to mount our customers.json file into the container.
But also notice the containers working directory is /app and in the app.py we load customers.json from the working directory which is the same directory as the app.
When mounting files to a container, docker effectively formats that path, so to mount our json file, we would lose our app.py because of the format.

Therefore it's always important to mount configs, secrets or data to seperate folders.

Let's change that to /data

#set a global variable 
dataPath = "/data/customers.json"

Now we can change all references to customers.json to our variable. And then mount that location:

docker build . -t customer-app
docker run -it -p 5000:5000 -v ${PWD}:/data customer-app