在現代的 Web 中 REST的設計風格為主流, 所以這篇文章就紀錄如何利用 Python & Flask 來設計一個 RESTful API 的系統. 主要參考designing a restful api with python and flask 這篇文章.

What is REST?

REST系統有知名的六條設計原理

  • Client-Server: There should be a separation between the server that offers a service, and the client that consumes it. (主從式架構設計, 這也是目前主流啦)

  • Stateless: Each request from a client must contain all the information required by the server to carry out the request. In other words, the server cannot store information provided by the client in one request and use it in another request. (簡單來說就是每個Request之間不互相影響)

  • Cacheable: The server must indicate to the client if requests can be cached or not. (可實作快取,且快取機制可以在 Client 或 Server 中實作)

  • Layered System: Communication between a client and a server should be standardized in such a way that allows intermediaries to respond to requests instead of the end server, without the client having to do anything different. (可分層的系統架構,目的在於隱藏介面後的實作細節,使得系統更容易實現快取、加密等等機制)

  • Uniform Interface: The method of communication between a client and a server must be uniform. (服務器和客戶端的通信方法必須是統一的)

  • Code on demand: Servers can provide executable code or scripts for clients to execute in their context. This constraint is the only one that is optional. (服務器可以提供可執行代碼或腳本,為客戶端在它們的環境中執行。這個約束是唯一一個是可選擇的)

What is a RESTful web service?

REST 架構的最初目的是適應 WEB 的 HTTP 協議。

RESTful web services 概念的核心就是"資源"。資源使用 URI 來表示. 客戶端使用 HTTP 協議來發送請求到這些 URIs,當然可能會導致這些被訪問的”資源“狀態的改變

下面是常見的 Http 的方法和它對應的含義:

HTTP Method Action Example
Get Obtain information about a resource http://example.com/api/orders (retrieve order list)
Post Create a new resource http://example.com/api/orders (create a new order, from data provided with the request)
Put Update a resource http://example.com/api/orders/123 (update order #123, from data provided with the request)
Delete Delete a resource http://example.com/api/orders/123 (delete order #123)

基本上在 RESTful web services 的架構中, 所有動作都透過這四個方法結合URL所對應的Resource來完成.

Designing a simple web service

Install

$ pip install virtualenv
$ mkdir todo-api
$ cd todo-api
$ virtualenv flask
$ flask/bin/pip install flask

Set flask

app.py

#!flask/bin/python
from flask import Flask

app = Flask(__name__)

@app.route('/')
def index():
    return "Hello, World!"

if __name__ == '__main__':
    app.run(host='127.0.0.1', debug=True)
$ chmod a+x app.py
$ ./app.py
 * Running on http://127.0.0.1:5000/
 * Restarting with reloader

打開你的瀏覽器, 輸入http://ip:5000, 應該可以看到hello world.

如果你希望暫時先讓所有人都可以連的話, 把127.0.0.1改成0.0.0.0即可.

Create web service

#!flask/bin/python
from flask import Flask, jsonify

app = Flask(__name__)

tasks = [
    {
        'id': 1,
        'title': u'Buy groceries',
        'description': u'Milk, Cheese, Pizza, Fruit, Tylenol',
        'done': False
    },
    {
        'id': 2,
        'title': u'Learn Python',
        'description': u'Need to find a good Python tutorial on the web',
        'done': False
    }
]

@app.route('/todo/api/v1.0/tasks', methods=['GET'])
def get_tasks():
    return jsonify({'tasks': tasks})

if __name__ == '__main__':
    app.run(host='127.0.0.1', debug=True)

現在有一個 get_tasks 的函數, 能透過 URL: http:ip:5000/todo/api/v1.0/tasks來拜訪(資料傳輸的方式為JSON). 但是限定只能透過Get的方式來存取.

那接著我們會透過curl來測試整個web service.

$ curl -i http://ip:5000/todo/api/v1.0/tasks
Content-Type: application/json
Content-Length: 294
Server: Werkzeug/0.8.3 Python/2.7.3
Date: Mon, 20 May 2013 04:53:53 GMT

{
  "tasks": [
    {
      "description": "Milk, Cheese, Pizza, Fruit, Tylenol",
      "done": false,
      "id": 1,
      "title": "Buy groceries"
    },
    {
      "description": "Need to find a good Python tutorial on the web",
      "done": false,
      "id": 2,
      "title": "Learn Python"
    }
  ]
}

接著是另外一種形式. 透過Get所帶的參數來決定回傳什麼樣的內容. Code如下:

from flask import abort

@app.route('/todo/api/v1.0/tasks/<int:task_id>', methods=['GET'])
def get_task(task_id):
    task = filter(lambda t: t['id'] == task_id, tasks)
    if len(task) == 0:
        abort(404)
    return jsonify({'task': task[0]})

測試一下

$ curl -i http://localhost:5000/todo/api/v1.0/tasks/2
HTTP/1.0 200 OK
Content-Type: application/json
Content-Length: 151
Server: Werkzeug/0.8.3 Python/2.7.3
Date: Mon, 20 May 2013 05:21:50 GMT

{
  "task": {
    "description": "Need to find a good Python tutorial on the web",
    "done": false,
    "id": 2,
    "title": "Learn Python"
  }
}
$ curl -i http://localhost:5000/todo/api/v1.0/tasks/3
HTTP/1.0 404 NOT FOUND
Content-Type: text/html
Content-Length: 238
Server: Werkzeug/0.8.3 Python/2.7.3
Date: Mon, 20 May 2013 05:21:52 GMT

<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 3.2 Final//EN">
<title>404 Not Found</title>
<h1>Not Found</h1>
<p>The requested URL was not found on the server.</p><p>If you     entered the URL manually please check your spelling and try again.</p>

那有時候會不小心發生404的狀況. 當然http本身就會吐自身的error回去. 但如果你希望符合一致性也透過JSON回傳的話. 可以使用Flask errorhandler來處理.

from flask import make_response

@app.errorhandler(404)
def not_found(error):
    return make_response(jsonify({'error': 'Not found'}), 404)
$ curl -i http://localhost:5000/todo/api/v1.0/tasks/3
HTTP/1.0 404 NOT FOUND
Content-Type: application/json
Content-Length: 26
Server: Werkzeug/0.8.3 Python/2.7.3
Date: Mon, 20 May 2013 05:36:54 GMT

{
  "error": "Not found"
}

接下來就是 POST 方法,我們用來在我們的任務數據庫中插入一個新的任務:

from flask import request

@app.route('/todo/api/v1.0/tasks', methods=['POST'])
def create_task():
    if not request.json or not 'title' in request.json:
        abort(400)
    task = {
        'id': tasks[-1]['id'] + 1,
        'title': request.json['title'],
        'description': request.json.get('description', ""),
        'done': False
    }
    tasks.append(task)
    return jsonify({'task': task}), 201

只有當請求以 JSON 格式形式,request.json 才會有請求的數據. 如果沒有數據,或者存在數據但是缺少 title 項,我們將會返回 400,這是表示請求無效.

$ curl -i -H "Content-Type: application/json" -X POST -d '{"title":"Read a book"}' http://localhost:5000/todo/api/v1.0/tasks
HTTP/1.0 201 Created
Content-Type: application/json
Content-Length: 104
Server: Werkzeug/0.8.3 Python/2.7.3
Date: Mon, 20 May 2013 05:56:21 GMT

{
  "task": {
    "description": "",
    "done": false,
    "id": 3,
    "title": "Read a book"
  }
}

Put & Delete 的情況, 作者則合併一起講, 可能是比較少用到的吧. (我自己平常也只用Get&Post而已, 看來之後得改一下, 萬惡的Post啊...)

@app.route('/todo/api/v1.0/tasks/<int:task_id>', methods=['PUT'])
def update_task(task_id):
    task = filter(lambda t: t['id'] == task_id, tasks)
    if len(task) == 0:
        abort(404)
    if not request.json:
        abort(400)
    if 'title' in request.json and type(request.json['title']) != unicode:
        abort(400)
    if 'description' in request.json and type(request.json['description']) is not unicode:
        abort(400)
    if 'done' in request.json and type(request.json['done']) is not bool:
        abort(400)
    task[0]['title'] = request.json.get('title', task[0]['title'])
    task[0]['description'] = request.json.get('description', task[0]['description'])
    task[0]['done'] = request.json.get('done', task[0]['done'])
    return jsonify({'task': task[0]})

@app.route('/todo/api/v1.0/tasks/<int:task_id>', methods=['DELETE'])
def delete_task(task_id):
    task = filter(lambda t: t['id'] == task_id, tasks)
    if len(task) == 0:
        abort(404)
    tasks.remove(task[0])
    return jsonify({'result': True})

Securing a RESTful web service

我們已經完成了我們 web service 的大部分功能,但是仍然有一個問題. 我們的 web service 對任何人都是公開的,這並不是一件好事. 所以有個 Flask module: Flask-HTTPAuth 能夠幫助我們.

$ flask/bin/pip install flask-httpauth

如果我們只希望 web service 只讓訪問用戶名 miguel 和密碼 python 的客戶端訪問.我們可以設置一個基本的 HTTP 驗證如下:

from flask.ext.httpauth import HTTPBasicAuth
auth = HTTPBasicAuth()

@auth.get_password
def get_password(username):
    if username == 'miguel':
        return 'python'
    return None

@auth.error_handler
def unauthorized():
    return make_response(jsonify({'error': 'Unauthorized access'}), 401)
@app.route('/todo/api/v1.0/tasks', methods=['GET'])
@auth.login_required
def get_tasks():
    return jsonify({'tasks': tasks})

使用 curl 測試:

$ curl -i http://ip:5000/todo/api/v1.0/tasks
HTTP/1.0 401 UNAUTHORIZED
Content-Type: application/json
Content-Length: 36
WWW-Authenticate: Basic realm="Authentication Required"
Server: Werkzeug/0.8.3 Python/2.7.3
Date: Mon, 20 May 2013 06:41:14 GMT

{
  "error": "Unauthorized access"
}

需代入驗證的資訊.

$ curl -u miguel:python -i http://ip:5000/todo/api/v1.0/tasks
HTTP/1.0 200 OK
Content-Type: application/json
Content-Length: 316
Server: Werkzeug/0.8.3 Python/2.7.3
Date: Mon, 20 May 2013 06:46:45 GMT

{
  "tasks": [
    {
      "title": "Buy groceries",
      "done": false,
      "description": "Milk, Cheese, Pizza, Fruit, Tylenol",
      "uri": "http://localhost:5000/todo/api/v1.0/tasks/1"
    },
    {
      "title": "Learn Python",
      "done": false,
      "description": "Need to find a good Python tutorial on the web",
      "uri": "http://localhost:5000/todo/api/v1.0/tasks/2"
    }
  ]
}

認證擴展給予我們很大的自由選擇哪些函數需要保護, 哪些函數需要公開.

Reference