marcel-dempers d47976ffa8 updates
2021-11-18 08:57:39 +11:00

459 lines
12 KiB
Markdown

# 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. </br>
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](../part-2.files) </br>
As well as [Part 3: JSON](../part-3.json) </br>
## Python Dev Environment
The same as Part 1, we start with a [dockerfile](./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 </br>
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. </br>
We've learnt how to read and write to file and temporarily use the file for our storage. </br>
```
{
"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](https://flask.palletsprojects.com/en/2.0.x/)
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. </br>
Flask is an external package. </br>
External packages are managed by [pip](https://pypi.org/project/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. </br>
With `pip`, we define our dependencies in a `requirements.txt` file.
We can check for the latest version of `Flask` [here](https://pypi.org/project/Flask/)
We can also use `pip install` commands to install dependencies. </br>
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.2
```
We can install our dependencies using:
```
pip install -r src/requirements.txt
```
Let's implement the minimal example we posted above. </br>
To run our application, note that the `app.py` runs as per usual. <br/>
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. </br>
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. </br>
Currently we can see logs showing `GET` requests for route `/` </br>
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](https://flask.palletsprojects.com/en/2.0.x/quickstart/#variable-rules)
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 </br>
`POST` method is for passing data to our service </br>
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 </br>
Status codes are important for troubleshooting production traffic.
`200` = Success. </br>
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. </br>
`500` = Internal Server Error </br>
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. </br>
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. </br>
Also note we're getting a `200` status code. </br>
This may not be super appropriate, as our customer we requested is technically not found. There is a status code for that </br>
`404` = Not Found </br>
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. </br>
More about `Requests` [here](https://flask.palletsprojects.com/en/2.0.x/api/#flask.Request)
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'}` </br>
This allows us to read customer data as JSON, validate it, and write it to storage. </br>
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 </br>
`400` = Bad Request </br>
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 ./src/requirements.txt /app/
RUN pip install -r /app/requirements.txt
COPY ./src/app.py /app/app.py
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. </br>
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. </br>
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. <br/>
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
```