Implementing a RESTful Web API with Python & Flask

Example Code

Hacker News Discussion

Introduction

To begin:

sudo pip install flask

I'm assuming you already know the basics of REST. If not, or if you want a quick refresh, I've written an introduction to Designing a RESTful Web API.

Flask is a microframework for Python based on Werkzeug, a WSGI utility library.

Flask is a good choice for a REST API because it is:

  • Written in Python (that can be an advantage);
  • Simple to use;
  • Flexible;
  • Multiple good deployment options;
  • RESTful request dispatching

I normally use curl to make test requests and there's a curl mini-reference at the end of this article. Another nice tool is REST Console for Google Chrome.

As a convention in this document, whenever a server response is presented, it is preceded by the HTTP request that was made to generate the particular response with any relevant parameters and headers. The request itself is not part of the response.

Resources

Let's begin by making a complete app that responds to requests at the root, /articles and /articles/:id.

from flask import Flask, url_for
app = Flask(__name__)

@app.route('/')
def api_root():
    return 'Welcome'

@app.route('/articles')
def api_articles():
    return 'List of ' + url_for('api_articles')

@app.route('/articles/<articleid>')
def api_article(articleid):
    return 'You are reading ' + articleid

if __name__ == '__main__':
    app.run()

You can use curl to make the requests using:

curl http://127.0.0.1:5000/

The responses will be, respectively,

GET /
Welcome

GET /articles
List of /articles

GET /articles/123
You are reading 123

Routes can use different converters in their definition,

@app.route('/articles/<articleid>')

Can be replaced by

@app.route('/articles/<int:articleid>')
@app.route('/articles/<float:articleid>')
@app.route('/articles/<path:articleid>')

The default is string which accepts any text without slashes.

Requests

Flask API Documentation: Incoming Request Data

GET Parameters

Lets begin by making a complete app that responds to requests at /hello and handles an optional GET parameter

from flask import request

@app.route('/hello')
def api_hello():
    if 'name' in request.args:
        return 'Hello ' + request.args['name']
    else:
        return 'Hello John Doe'

the server will reply in the following manner:

GET /hello
Hello John Doe

GET /hello?name=Luis
Hello Luis

Request Methods (HTTP Verbs)

Lets modify the to handle different HTTP verbs:

@app.route('/echo', methods = ['GET', 'POST', 'PATCH', 'PUT', 'DELETE'])
def api_echo():
    if request.method == 'GET':
        return "ECHO: GET\n"

    elif request.method == 'POST':
        return "ECHO: POST\n"

    elif request.method == 'PATCH':
        return "ECHO: PACTH\n"

    elif request.method == 'PUT':
        return "ECHO: PUT\n"

    elif request.method == 'DELETE':
        return "ECHO: DELETE"

To curl the -X option can be used to specify the request type:

curl -X PATCH http://127.0.0.1:5000/echo

The replies to the different request methods will be:

GET /echo
ECHO: GET

POST /ECHO
ECHO: POST

and so on.

Request Data & Headers

Usually POST and PATCH are accompanied by data. And sometimes that data can be in one of multiple formats: plain text, JSON, XML, your own data format, a binary file, …

Accessing the HTTP headers is done using the request.headers dictionary ("dictionary-like object") and the request data using the request.data string. As a convenience, if the mimetype is application/json, request.json will contain the parsed JSON.

from flask import json

@app.route('/messages', methods = ['POST'])
def api_message():

    if request.headers['Content-Type'] == 'text/plain':
        return "Text Message: " + request.data

    elif request.headers['Content-Type'] == 'application/json':
        return "JSON Message: " + json.dumps(request.json)

    elif request.headers['Content-Type'] == 'application/octet-stream':
        f = open('./binary', 'wb')
        f.write(request.data)
                f.close()
        return "Binary message written!"

    else:
        return "415 Unsupported Media Type ;)"

To specify the content type with curl:

curl -H "Content-type: application/json" \
-X POST http://127.0.0.1:5000/messages -d '{"message":"Hello Data"}'

To send a file with curl:

curl -H "Content-type: application/octet-stream" \
-X POST http://127.0.0.1:5000/messages --data-binary @message.bin

The replies to the different content types will be:

POST /messages {"message": "Hello Data"}
Content-type: application/json
JSON Message: {"message": "Hello Data"}

POST /message <message.bin>
Content-type: application/octet-stream
Binary message written!

Also note that Flask can handle files POSTed via an HTML form using request.files and curl can simulate that behavior with the -F flag.

Responses

Responses are handled by Flask's Response class:

from flask import Response

@app.route('/hello', methods = ['GET'])
def api_hello():
    data = {
        'hello'  : 'world',
        'number' : 3
    }
    js = json.dumps(data)

    resp = Response(js, status=200, mimetype='application/json')
    resp.headers['Link'] = 'http://luisrei.com'

    return resp

To view the response HTTP headers using curl, specify the -i option:

curl -i http://127.0.0.1:5000/hello

The response returned by the server, with headers included, will be:

GET /hello
HTTP/1.0 200 OK
Content-Type: application/json
Content-Length: 31
Link: http://luisrei.com
Server: Werkzeug/0.8.2 Python/2.7.1
Date: Wed, 25 Apr 2012 16:40:27 GMT
{"hello": "world", "number": 3}

Mimetype is just the content-type without the additional information (e.g. charset, encoding, language,…). If possible, return the the full content type information.

The previous example can be further simplified by using a Flask convenience method for generating JSON responses:

   from flask import jsonify

and replacing

 resp = Response(js, status=200, mimetype='application/json')

with

resp = jsonify(data)
resp.status_code = 200

which will generate the exact same response as the previous code.

Specifying the mime type is particularly useful when using a custom mime type e.g. application/vnd.example.v2+json.

Status Codes & Errors

Note that 200 is the default status code reply for GET requests, in both of these examples, specifying it was just for the sake of illustration. There are certain cases where overriding the defaults is necessary. Such is the case with error handling:

@app.errorhandler(404)
def not_found(error=None):
    message = {
            'status': 404,
            'message': 'Not Found: ' + request.url,
    }
    resp = jsonify(message)
    resp.status_code = 404

    return resp

@app.route('/users/<userid>', methods = ['GET'])
def api_users(userid):
    users = {'1':'john', '2':'steve', '3':'bill'}
    
    if userid in users:
        return jsonify({userid:users[userid]})
    else:
        return not_found()

This produces:

GET /users/2
HTTP/1.0 200 OK
{
    "2": "steve"
}

GET /users/4
HTTP/1.0 404 NOT FOUND
{
"status": 404, 
"message": "Not Found: http://127.0.0.1:5000/users/4"
}

Default Flask error messages can be overwritten using either the @error_handler decorator or

app.error_handler_spec[None][404] = not_found

Even if the API does not need custom error messages, if supports different mime types (JSON, XML, …) this feature is important because Flask defaults to HTML errors.

There's a snippet by Pavel Repin that shows how to automatically replace all the default error messages with their JSON equivalents.

Authorization

Another very useful snippet by Armin Ronacher shows how to handle HTTP Basic Authentication and can be easily modified to handle other schemes. I have slightly modified it:

from functools import wraps

def check_auth(username, password):
    return username == 'admin' and password == 'secret'

def authenticate():
    message = {'message': "Authenticate."}
    resp = jsonify(message)

    resp.status_code = 401
    resp.headers['WWW-Authenticate'] = 'Basic realm="Example"'

    return resp

def requires_auth(f):
    @wraps(f)
    def decorated(*args, **kwargs):
        auth = request.authorization
        if not auth: 
            return authenticate()

        elif not check_auth(auth.username, auth.password):
            return authenticate()
        return f(*args, **kwargs)

    return decorated

And using it is a matter of replacing the check_auth function and using the requires_auth decorator:

@app.route('/secrets')
@requires_auth
def api_hello():
    return "Shhh this is top secret spy stuff!"

So now, and unauthenticated request:

GET /secrets
HTTP/1.0 401 UNAUTHORIZED
WWW-Authenticate: Basic realm="Example"
{
  "message": "Authenticate."
}

While an authenticated request which can be made with curl using the -u option to use HTTP basic authentication and the -voption to look at the headers in the request

curl -v -u "admin:secret" http://127.0.0.1:5000/secrets

results in the expected response

GET /secrets Authorization: Basic YWRtaW46c2VjcmV0
Shhh this is top secret spy stuff!

Flask uses a MultiDict to store the headers. To present clients with multiple possible authentication schemes it is possible to simply add more WWW-Authenticate lines to the header

resp.headers['WWW-Authenticate'] = 'Basic realm="Example"'
resp.headers.add('WWW-Authenticate', 'Bearer realm="Example"')

or use a single line with multiple schemes (the standard allows both).

Simple Debug & Logging

Activating pretty (HTML) debug messages during development can be done simply by passing an argument

app.run(debug=True)

Flask uses python logging off the box - some configuration required:

import logging
file_handler = logging.FileHandler('app.log')
app.logger.addHandler(file_handler)
app.logger.setLevel(logging.INFO)

@app.route('/hello', methods = ['GET'])
def api_hello():
    app.logger.info('informing')
    app.logger.warning('warning')
    app.logger.error('screaming bloody murder!')
    
    return "check your logs\n"

Mini-Reference: curl options

option purpose
-X specify HTTP request method e.g. POST
-H specify request headers e.g. "Content-type: application/json"
-d specify request data e.g. '{"message":"Hello Data"}'
--data-binary specify binary request data e.g. @file.bin
-i shows the response headers
-u specify username and password e.g. "admin:secret"
-v enables verbose mode which outputs info such as request and response headers and errors

Bibliography

Flask documentation

Flask snippets

Werkzeug documentation

curl manual

Tagged: rest   python  


comments powered by Disqus