# 使用 Flask 設計 RESTful APIs
翻譯者注:本系列的原文名為:[Designing a RESTful API with Python and Flask](http://blog.miguelgrinberg.com/post/designing-a-restful-api-with-python-and-flask) ,作者是 **Miguel Grinberg** 。
# 使用 Python 和 Flask 設計 RESTful API
近些年來 REST (REpresentational State Transfer) 已經變成了 web services 和 web APIs 的標配。
在本文中我將向你展示如何簡單地使用 Python 和 Flask 框架來創建一個 RESTful 的 web service。
## 什么是 REST?
六條設計規范定義了一個 REST 系統的特點:
* **客戶端-服務器**: 客戶端和服務器之間隔離,服務器提供服務,客戶端進行消費。
* **無狀態**: 從客戶端到服務器的每個請求都必須包含理解請求所必需的信息。換句話說, 服務器不會存儲客戶端上一次請求的信息用來給下一次使用。
* **可緩存**: 服務器必須明示客戶端請求能否緩存。
* **分層系統**: 客戶端和服務器之間的通信應該以一種標準的方式,就是中間層代替服務器做出響應的時候,客戶端不需要做任何變動。
* **統一的接口**: 服務器和客戶端的通信方法必須是統一的。
* **按需編碼**: 服務器可以提供可執行代碼或腳本,為客戶端在它們的環境中執行。這個約束是唯一一個是可選的。
## 什么是一個 RESTful 的 web service?
REST 架構的最初目的是適應萬維網的 HTTP 協議。
RESTful web services 概念的核心就是“資源”。 資源可以用 [URI](https://en.wikipedia.org/wiki/Uniform_resource_identifier) 來表示。客戶端使用 HTTP 協議定義的方法來發送請求到這些 URIs,當然可能會導致這些被訪問的”資源“狀態的改變。
HTTP 標準的方法有如下:
```
========== ===================== ==================================
HTTP 方法 行為 示例
========== ===================== ==================================
GET 獲取資源的信息 http://example.com/api/orders
GET 獲取某個特定資源的信息 http://example.com/api/orders/123
POST 創建新資源 http://example.com/api/orders
PUT 更新資源 http://example.com/api/orders/123
DELETE 刪除資源 http://example.com/api/orders/123
========== ====================== ==================================
```
REST 設計不需要特定的數據格式。在請求中數據可以以 [JSON](http://en.wikipedia.org/wiki/JSON) 形式, 或者有時候作為 url 中查詢參數項。
## 設計一個簡單的 web service
堅持 REST 的準則設計一個 web service 或者 API 的任務就變成一個標識資源被展示出來以及它們是怎樣受不同的請求方法影響的練習。
比如說,我們要編寫一個待辦事項應用程序而且我們想要為它設計一個 web service。要做的第一件事情就是決定用什么樣的根 URL 來訪問該服務。例如,我們可以通過這個來訪問:
[http://[hostname]/todo/api/v1.0/](http://[hostname]/todo/api/v1.0/)
在這里我已經決定在 URL 中包含應用的名稱以及 API 的版本號。在 URL 中包含應用名稱有助于提供一個命名空間以便區分同一系統上的其它服務。在 URL 中包含版本號能夠幫助以后的更新,如果新版本中存在新的和潛在不兼容的功能,可以不影響依賴于較舊的功能的應用程序。
下一步驟就是選擇將由該服務暴露(展示)的資源。這是一個十分簡單地應用,我們只有任務,因此在我們待辦事項中唯一的資源就是任務。
我們的任務資源將要使用 HTTP 方法如下:
```
========== =============================================== =============================
HTTP 方法 URL 動作
========== =============================================== ==============================
GET http://[hostname]/todo/api/v1.0/tasks 檢索任務列表
GET http://[hostname]/todo/api/v1.0/tasks/[task_id] 檢索某個任務
POST http://[hostname]/todo/api/v1.0/tasks 創建新任務
PUT http://[hostname]/todo/api/v1.0/tasks/[task_id] 更新任務
DELETE http://[hostname]/todo/api/v1.0/tasks/[task_id] 刪除任務
========== ================================================ =============================
```
我們定義的任務有如下一些屬性:
* **id**: 任務的唯一標識符。數字類型。
* **title**: 簡短的任務描述。字符串類型。
* **description**: 具體的任務描述。文本類型。
* **done**: 任務完成的狀態。布爾值。
目前為止關于我們的 web service 的設計基本完成。剩下的事情就是實現它!
## Flask 框架的簡介
如果你讀過 [Flask Mega-Tutorial 系列](http://www.pythondoc.com/flask-mega-tutorial/index.html),就會知道 Flask 是一個簡單卻十分強大的 Python web 框架。
在我們深入研究 web services 的細節之前,讓我們回顧一下一個普通的 Flask Web 應用程序的結構。
我會首先假設你知道 Python 在你的平臺上工作的基本知識。 我將講解的例子是工作在一個類 Unix 操作系統。簡而言之,這意味著它們能工作在 Linux,Mac OS X 和 Windows(如果你使用Cygwin)。 如果你使用 Windows 上原生的 Python 版本的話,命令會有所不同。
讓我們開始在一個虛擬環境上安裝 Flask。如果你的系統上沒有 virtualenv,你可以從 [https://pypi.python.org/pypi/virtualenv](https://pypi.python.org/pypi/virtualenv) 上下載:
```
$ mkdir todo-api
$ cd todo-api
$ virtualenv flask
New python executable in flask/bin/python
Installing setuptools............................done.
Installing pip...................done.
$ flask/bin/pip install flask
```
既然已經安裝了 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(debug=True)
```
為了運行這個程序我們必須執行 app.py:
```
$ chmod a+x app.py
$ ./app.py
* Running on http://127.0.0.1:5000/
* Restarting with reloader
```
現在你可以啟動你的網頁瀏覽器,輸入 [http://localhost:5000](http://localhost:5000) 看看這個小應用程序的效果。
簡單吧?現在我們將這個應用程序轉換成我們的 RESTful service!
## 使用 Python 和 Flask 實現 RESTful services
使用 Flask 構建 web services 是十分簡單地,比我在 [Mega-Tutorial](http://www.pythondoc.com/flask-mega-tutorial/index.html) 中構建的完整的服務端的應用程序要簡單地多。
在 Flask 中有許多擴展來幫助我們構建 RESTful services,但是在我看來這個任務十分簡單,沒有必要使用 Flask 擴展。
我們 web service 的客戶端需要添加、刪除以及修改任務的服務,因此顯然我們需要一種方式來存儲任務。最直接的方式就是建立一個小型的數據庫,但是數據庫并不是本文的主體。學習在 Flask 中使用合適的數據庫,我強烈建議閱讀 [Mega-Tutorial](http://www.pythondoc.com/flask-mega-tutorial/index.html)。
這里我們直接把任務列表存儲在內存中,因此這些任務列表只會在 web 服務器運行中工作,在結束的時候就失效。 這種方式只是適用我們自己開發的 web 服務器,不適用于生產環境的 web 服務器, 這種情況一個合適的數據庫的搭建是必須的。
我們現在來實現 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(debug=True)
```
正如你所見,沒有多大的變化。我們創建一個任務的內存數據庫,這里無非就是一個字典和數組。數組中的每一個元素都具有上述定義的任務的屬性。
取代了首頁,我們現在擁有一個 get_tasks 的函數,訪問的 URI 為 /todo/api/v1.0/tasks,并且只允許 GET 的 HTTP 方法。
這個函數的響應不是文本,我們使用 JSON 數據格式來響應,Flask 的 jsonify 函數從我們的數據結構中生成。
使用網頁瀏覽器來測試我們的 web service 不是一個最好的注意,因為網頁瀏覽器上不能輕易地模擬所有的 HTTP 請求的方法。相反,我們會使用 curl。如果你還沒有安裝 curl 的話,請立即安裝它。
通過執行 app.py,啟動 web service。接著打開一個新的控制臺窗口,運行以下命令:
```
$ curl -i http://localhost:5000/todo/api/v1.0/tasks
HTTP/1.0 200 OK
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"
}
]
}
```
我們已經成功地調用我們的 RESTful service 的一個函數!
現在我們開始編寫 GET 方法請求我們的任務資源的第二個版本。這是一個用來返回單獨一個任務的函數:
```
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]})
```
第二個函數有些意思。這里我們得到了 URL 中任務的 id,接著 Flask 把它轉換成 函數中的 task_id 的參數。
我們用這個參數來搜索我們的任務數組。如果我們的數據庫中不存在搜索的 id,我們將會返回一個類似 404 的錯誤,根據 HTTP 規范的意思是 “資源未找到”。
如果我們找到相應的任務,那么我們只需將它用 jsonify 打包成 JSON 格式并將其發送作為響應,就像我們以前那樣處理整個任務集合。
調用 curl 請求的結果如下:
```
$ 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>
```
當我們請求 id #2 的資源時候,我們獲取到了,但是當我們請求 #3 的時候返回了 404 錯誤。有關錯誤奇怪的是返回的是 HTML 信息而不是 JSON,這是因為 Flask 按照默認方式生成 404 響應。由于這是一個 Web service 客戶端希望我們總是以 JSON 格式回應,所以我們需要改善我們的 404 錯誤處理程序:
```
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,這是表示請求無效。
接著我們會創建一個新的任務字典,使用最后一個任務的 id + 1 作為該任務的 id。我們允許 description 字段缺失,并且假設 done 字段設置成 False。
我們把新的任務添加到我們的任務數組中,并且把新添加的任務和狀態 201 響應給客戶端。
使用如下的 curl 命令來測試這個新的函數:
```
$ 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"
}
}
```
注意:如果你在 Windows 上并且運行 Cygwin 版本的 curl,上面的命令不會有任何問題。然而,如果你使用原生的 curl,命令會有些不同:
```
curl -i -H "Content-Type: application/json" -X POST -d "{"""title""":"""Read a book"""}" http://localhost:5000/todo/api/v1.0/tasks
```
當然在完成這個請求后,我們可以得到任務的更新列表:
```
$ curl -i http://localhost:5000/todo/api/v1.0/tasks
HTTP/1.0 200 OK
Content-Type: application/json
Content-Length: 423
Server: Werkzeug/0.8.3 Python/2.7.3
Date: Mon, 20 May 2013 05:57:44 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"
},
{
"description": "",
"done": false,
"id": 3,
"title": "Read a book"
}
]
}
```
剩下的兩個函數如下所示:
```
@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})
```
delete_task 函數沒有什么特別的。對于 update_task 函數,我們需要嚴格地檢查輸入的參數以防止可能的問題。我們需要確保在我們把它更新到數據庫之前,任何客戶端提供我們的是預期的格式。
更新任務 #2 的函數調用如下所示:
```
$ curl -i -H "Content-Type: application/json" -X PUT -d '{"done":true}' http://localhost:5000/todo/api/v1.0/tasks/2
HTTP/1.0 200 OK
Content-Type: application/json
Content-Length: 170
Server: Werkzeug/0.8.3 Python/2.7.3
Date: Mon, 20 May 2013 07:10:16 GMT
{
"task": [
{
"description": "Need to find a good Python tutorial on the web",
"done": true,
"id": 2,
"title": "Learn Python"
}
]
}
```
## 優化 web service 接口
目前 API 的設計的問題就是迫使客戶端在任務標識返回后去構造 URIs。這對于服務器是十分簡單的,但是間接地迫使客戶端知道這些 URIs 是如何構造的,這將會阻礙我們以后變更這些 URIs。
不直接返回任務的 ids,我們直接返回控制這些任務的完整的 URI,以便客戶端可以隨時使用這些 URIs。為此,我們可以寫一個小的輔助函數生成一個 “公共” 版本任務發送到客戶端:
```
from flask import url_for
def make_public_task(task):
new_task = {}
for field in task:
if field == 'id':
new_task['uri'] = url_for('get_task', task_id=task['id'], _external=True)
else:
new_task[field] = task[field]
return new_task
```
這里所有做的事情就是從我們數據庫中取出任務并且創建一個新的任務,這個任務的 id 字段被替換成通過 Flask 的 url_for 生成的 uri 字段。
當我們返回所有的任務列表的時候,在發送到客戶端之前通過這個函數進行處理:
```
@app.route('/todo/api/v1.0/tasks', methods=['GET'])
def get_tasks():
return jsonify({'tasks': map(make_public_task, tasks)})
```
這里就是客戶端獲取任務列表的時候得到的數據:
```
$ curl -i http://localhost:5000/todo/api/v1.0/tasks
HTTP/1.0 200 OK
Content-Type: application/json
Content-Length: 406
Server: Werkzeug/0.8.3 Python/2.7.3
Date: Mon, 20 May 2013 18:16:28 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"
}
]
}
```
我們將會把上述的方式應用到其它所有的函數上以確保客戶端一直看到 URIs 而不是 ids。
## 加強 RESTful web service 的安全性
我們已經完成了我們 web service 的大部分功能,但是仍然有一個問題。我們的 web service 對任何人都是公開的,這并不是一個好主意。
我們有一個可以管理我們的待辦事項完整的 web service,但在當前狀態下的 web service 是開放給所有的客戶端。 如果一個陌生人弄清我們的 API 是如何工作的,他或她可以編寫一個客戶端訪問我們的 web service 并且毀壞我們的數據。
大部分初級的教程會忽略這個問題并且到此為止。在我看來這是一個很嚴重的問題,我必須指出。
確保我們的 web service 安全服務的最簡單的方法是要求客戶端提供一個用戶名和密碼。在常規的 web 應用程序會提供一個登錄的表單用來認證,并且服務器會創建一個會話為登錄的用戶以后的操作使用,會話的 id 以 cookie 形式存儲在客戶端瀏覽器中。然而 REST 的規則之一就是 “無狀態”, 因此我們必須要求客戶端在每一次請求中提供認證的信息。
我們一直試著盡可能地堅持 HTTP 標準協議。既然我們需要實現認證我們需要在 HTTP 上下文中去完成,HTTP 協議提供了兩種認證機制: [Basic 和 Digest](http://www.ietf.org/rfc/rfc2617.txt)。
有一個小的 Flask 擴展能夠幫助我們,我們可以先安裝 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)
```
get_password 函數是一個回調函數,Flask-HTTPAuth 使用它來獲取給定用戶的密碼。在一個更復雜的系統中,這個函數是需要檢查一個用戶數據庫,但是在我們的例子中只有單一的用戶因此沒有必要。
error_handler 回調函數是用于給客戶端發送未授權錯誤代碼。像我們處理其它的錯誤代碼,這里我們定制一個包含 JSON 數據格式而不是 HTML 的響應。
隨著認證系統的建立,所剩下的就是把需要認證的函數添加 @auth.login_required 裝飾器。例如:
```
@app.route('/todo/api/v1.0/tasks', methods=['GET'])
@auth.login_required
def get_tasks():
return jsonify({'tasks': tasks})
```
如果現在要嘗試使用 curl 調用這個函數我們會得到:
```
$ curl -i http://localhost: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://localhost: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"
}
]
}
```
認證擴展給予我們很大的自由選擇哪些函數需要保護,哪些函數需要公開。
為了確保登錄信息的安全應該使用 HTTP 安全服務器(例如: [https://](https://)...),這樣客戶端和服務器之間的通信都是加密的,以防止傳輸過程中第三方看到認證的憑據。
讓人不舒服的是當請求收到一個 401 的錯誤,網頁瀏覽都會跳出一個丑陋的登錄框,即使請求是在后臺發生的。因此如果我們要實現一個完美的 web 服務器的話,我們就需要禁止跳轉到瀏覽器顯示身份驗證對話框,讓我們的客戶端應用程序自己處理登錄。
一個簡單的方式就是不返回 401 錯誤。403 錯誤是一個令人青睞的替代,403 錯誤表示 “禁止” 的錯誤:
```
@auth.error_handler
def unauthorized():
return make_response(jsonify({'error': 'Unauthorized access'}), 403)
```
## 可能的改進
我們編寫的小型的 web service 還可以在不少的方面進行改進。
對于初學者來說,一個真正的 web service 需要一個真實的數據庫進行支撐。我們現在使用的內存數據結構會有很多限制不應該被用于真正的應用。
另外一個可以提高的領域就是處理多用戶。如果系統支持多用戶的話,不同的客戶端可以發送不同的認證憑證獲取相應用戶的任務列表。在這樣一個系統中的話,我們需要第二個資源就是用戶。在用戶資源上的 POST 的請求代表注冊換一個新用戶。一個 GET 請求表示客戶端獲取一個用戶的信息。一個 PUT 請求表示更新用戶信息,比如可能是更新郵箱地址。一個 DELETE 請求表示刪除用戶賬號。
GET 檢索任務列表請求可以在幾個方面進行擴展。首先可以攜帶一個可選的頁的參數,以便客戶端請求任務的一部分。另外,這種擴展更加有用:允許按照一定的標準篩選。比如,用戶只想要看到完成的任務,或者只想看到任務的標題以 A 字母開頭。所有的這些都可以作為 URL 的一個參數項。
# 使用 Flask-RESTful 設計 RESTful API
前面我已經用 Flask 實現了一個 RESTful 服務器。今天我們將會使用 Flask-RESTful 來實現同一個 RESTful 服務器,Flask-RESTful 是一個可以簡化 APIs 的構建的 Flask 擴展。
## RESTful 服務器
作為一個提醒, 這里就是待完成事項列表 web service 所提供的方法的定義:
```
========== =============================================== =============================
HTTP 方法 URL 動作
========== =============================================== ==============================
GET http://[hostname]/todo/api/v1.0/tasks 檢索任務列表
GET http://[hostname]/todo/api/v1.0/tasks/[task_id] 檢索某個任務
POST http://[hostname]/todo/api/v1.0/tasks 創建新任務
PUT http://[hostname]/todo/api/v1.0/tasks/[task_id] 更新任務
DELETE http://[hostname]/todo/api/v1.0/tasks/[task_id] 刪除任務
========== ================================================ =============================
```
這個服務唯一的資源叫做“任務”,它有如下一些屬性:
* **id**: 任務的唯一標識符。數字類型。
* **title**: 簡短的任務描述。字符串類型。
* **description**: 具體的任務描述。文本類型。
* **done**: 任務完成的狀態。布爾值。
## 路由
在上一遍文章中,我使用了 Flask 的視圖函數來定義所有的路由。
Flask-RESTful 提供了一個 Resource 基礎類,它能夠定義一個給定 URL 的一個或者多個 HTTP 方法。例如,定義一個可以使用 HTTP 的 GET, PUT 以及 DELETE 方法的 User 資源,你的代碼可以如下:
```
from flask import Flask
from flask.ext.restful import Api, Resource
app = Flask(__name__)
api = Api(app)
class UserAPI(Resource):
def get(self, id):
pass
def put(self, id):
pass
def delete(self, id):
pass
api.add_resource(UserAPI, '/users/<int:id>', endpoint = 'user')
```
add_resource 函數使用指定的 endpoint 注冊路由到框架上。如果沒有指定 endpoint,Flask-RESTful 會根據類名生成一個,但是有時候有些函數比如 url_for 需要 endpoint,因此我會明確給 endpoint 賦值。
我的待辦事項 API 定義兩個 URLs:/todo/api/v1.0/tasks(獲取所有任務列表),以及 /todo/api/v1.0/tasks/<int:id>(獲取單個任務)。我們現在需要兩個資源:
```
class TaskListAPI(Resource):
def get(self):
pass
def post(self):
pass
class TaskAPI(Resource):
def get(self, id):
pass
def put(self, id):
pass
def delete(self, id):
pass
api.add_resource(TaskListAPI, '/todo/api/v1.0/tasks', endpoint = 'tasks')
api.add_resource(TaskAPI, '/todo/api/v1.0/tasks/<int:id>', endpoint = 'task')
```
## 解析以及驗證請求
當我在以前的文章中實現此服務器的時候,我自己對請求的數據進行驗證。例如,在之前版本中如何處理 PUT 的:
```
@app.route('/todo/api/v1.0/tasks/<int:task_id>', methods = ['PUT'])
@auth.login_required
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': make_public_task(task[0]) } )
```
在這里, 我必須確保請求中給出的數據在使用之前是有效,這樣使得函數變得又臭又長。
Flask-RESTful 提供了一個更好的方式來處理數據驗證,它叫做 RequestParser 類。這個類工作方式類似命令行解析工具 argparse。
首先,對于每一個資源需要定義參數以及怎樣驗證它們:
```
from flask.ext.restful import reqparse
class TaskListAPI(Resource):
def __init__(self):
self.reqparse = reqparse.RequestParser()
self.reqparse.add_argument('title', type = str, required = True,
help = 'No task title provided', location = 'json')
self.reqparse.add_argument('description', type = str, default = "", location = 'json')
super(TaskListAPI, self).__init__()
# ...
class TaskAPI(Resource):
def __init__(self):
self.reqparse = reqparse.RequestParser()
self.reqparse.add_argument('title', type = str, location = 'json')
self.reqparse.add_argument('description', type = str, location = 'json')
self.reqparse.add_argument('done', type = bool, location = 'json')
super(TaskAPI, self).__init__()
# ...
```
在 TaskListAPI 資源中,POST 方法是唯一接收參數的。參數“標題”是必須的,因此我定義一個缺少“標題”的錯誤信息。當客戶端缺少這個參數的時候,Flask-RESTful 將會把這個錯誤信息作為響應發送給客戶端。“描述”字段是可選的,當缺少這個字段的時候,默認的空字符串將會被使用。一個有趣的方面就是 RequestParser 類默認情況下在 request.values 中查找參數,因此 location 可選參數必須被設置以表明請求過來的參數是 request.json 格式的。
TaskAPI 資源的參數處理是同樣的方式,但是有少許不同。PUT 方法需要解析參數,并且這個方法的所有參數都是可選的。
當請求解析器被初始化,解析和驗證一個請求是很容易的。 例如,請注意 TaskAPI.put() 方法變的多么地簡單:
```
def put(self, id):
task = filter(lambda t: t['id'] == id, tasks)
if len(task) == 0:
abort(404)
task = task[0]
args = self.reqparse.parse_args()
for k, v in args.iteritems():
if v != None:
task[k] = v
return jsonify( { 'task': make_public_task(task) } )
```
使用 Flask-RESTful 來處理驗證的另一個好處就是沒有必要單獨地處理類似 HTTP 400 錯誤,Flask-RESTful 會來處理這些。
## 生成響應
原來設計的 REST 服務器使用 Flask 的 jsonify 函數來生成響應。Flask-RESTful 會自動地處理轉換成 JSON 數據格式,因此下面的代碼需要替換:
```
return jsonify( { 'task': make_public_task(task) } )
```
現在需要寫成這樣:
```
return { 'task': make_public_task(task) }
```
Flask-RESTful 也支持自定義狀態碼,如果有必要的話:
```
return { 'task': make_public_task(task) }, 201
```
Flask-RESTful 還有更多的功能。make_public_task 能夠把來自原始服務器上的任務從內部形式包裝成客戶端想要的外部形式。最典型的就是把任務的 id 轉成 uri。Flask-RESTful 就提供一個輔助函數能夠很優雅地做到這樣的轉換,不僅僅能夠把 id 轉成 uri 并且能夠轉換其他的參數:
```
from flask.ext.restful import fields, marshal
task_fields = {
'title': fields.String,
'description': fields.String,
'done': fields.Boolean,
'uri': fields.Url('task')
}
class TaskAPI(Resource):
# ...
def put(self, id):
# ...
return { 'task': marshal(task, task_fields) }
```
task_fields 結構用于作為 marshal 函數的模板。fields.Uri 是一個用于生成一個 URL 的特定的參數。 它需要的參數是 endpoint。
## 認證
在 REST 服務器中的路由都是由 HTTP 基本身份驗證保護著。在最初的那個服務器是通過使用 Flask-HTTPAuth 擴展來實現的。
因為 Resouce 類是繼承自 Flask 的 MethodView,它能夠通過定義 decorators 變量并且把裝飾器賦予給它:
```
from flask.ext.httpauth import HTTPBasicAuth
# ...
auth = HTTPBasicAuth()
# ...
class TaskAPI(Resource):
decorators = [auth.login_required]
# ...
class TaskAPI(Resource):
decorators = [auth.login_required]
# ...
```
# 使用 Flask 設計 RESTful 的認證
今天我將要展示一個簡單,不過很安全的方式用來保護使用 Flask 編寫的 API,它是使用密碼或者令牌認證的。
## 示例代碼
本文使用的代碼能夠在 github 上找到: [REST-auth](https://github.com/miguelgrinberg/REST-auth) 。
## 用戶數據庫
為了讓給出的示例看起來像真實的項目,這里我將使用 Flask-SQLAlchemy 來構建用戶數據庫模型并且存儲到數據庫中。
用戶的數據庫模型是十分簡單的。對于每一個用戶,username 和 password_hash 將會被存儲:
```
class User(db.Model):
__tablename__ = 'users'
id = db.Column(db.Integer, primary_key = True)
username = db.Column(db.String(32), index = True)
password_hash = db.Column(db.String(128))
```
出于安全原因,用戶的原始密碼將不被存儲,密碼在注冊時被散列后存儲到數據庫中。使用散列密碼的話,如果用戶數據庫不小心落入惡意攻擊者的手里,他們也很難從散列中解析到真實的密碼。
密碼 **決不能** 很明確地存儲在用戶數據庫中。
## 密碼散列
為了創建密碼散列,我將會使用 PassLib 庫,一個專門用于密碼散列的 Python 包。
PassLib 提供了多種散列算法供選擇。custom_app_context 是一個易于使用的基于 sha256_crypt 的散列算法。
User 用戶模型需要增加兩個新方法來增加密碼散列和密碼驗證功能:
```
from passlib.apps import custom_app_context as pwd_context
class User(db.Model):
# ...
def hash_password(self, password):
self.password_hash = pwd_context.encrypt(password)
def verify_password(self, password):
return pwd_context.verify(password, self.password_hash)
```
hash_password() 函數接受一個明文的密碼作為參數并且存儲明文密碼的散列。當一個新用戶注冊到服務器或者當用戶修改密碼的時候,這個函數將被調用。
verify_password() 函數接受一個明文的密碼作為參數并且當密碼正確的話返回 True 或者密碼錯誤的話返回 False。這個函數當用戶提供和需要驗證憑證的時候調用。
你可能會問如果原始密碼散列后如何驗證原始密碼的?
散列算法是單向函數,這就是意味著它們能夠用于根據密碼生成散列,但是無法根據生成的散列逆向猜測出原密碼。然而這些算法是具有確定性的,給定相同的輸入它們總會得到相同的輸出。PassLib 所有需要做的就是驗證密碼,通過使用注冊時候同一個函數散列密碼并且同存儲在數據庫中的散列值進行比較。
## 用戶注冊
在本文例子中,一個客戶端可以使用 POST 請求到 /api/users 上注冊一個新用戶。請求的主體必須是一個包含 username 和 password 的 JSON 格式的對象。
Flask 中的路由的實現如下所示:
```
@app.route('/api/users', methods = ['POST'])
def new_user():
username = request.json.get('username')
password = request.json.get('password')
if username is None or password is None:
abort(400) # missing arguments
if User.query.filter_by(username = username).first() is not None:
abort(400) # existing user
user = User(username = username)
user.hash_password(password)
db.session.add(user)
db.session.commit()
return jsonify({ 'username': user.username }), 201, {'Location': url_for('get_user', id = user.id, _external = True)}
```
這個函數是十分簡單地。參數 username 和 password 是從請求中攜帶的 JSON 數據中獲取,接著驗證它們。
如果參數通過驗證的話,新的 User 實例被創建。username 賦予給 User,接著使用 hash_password 方法散列密碼。用戶最終被寫入數據庫中。
響應的主體是一個表示用戶的 JSON 對象,201 狀態碼以及一個指向新創建的用戶的 URI 的 HTTP 頭信息:Location。
注意:get_user 函數可以在 github 上找到完整的代碼。
這里是一個用戶注冊的請求,發送自 curl:
```
$ curl -i -X POST -H "Content-Type: application/json" -d '{"username":"miguel","password":"python"}' http://127.0.0.1:5000/api/users
HTTP/1.0 201 CREATED
Content-Type: application/json
Content-Length: 27
Location: http://127.0.0.1:5000/api/users/1
Server: Werkzeug/0.9.4 Python/2.7.3
Date: Thu, 28 Nov 2013 19:56:39 GMT
{
"username": "miguel"
}
```
需要注意地是在真實的應用中這里可能會使用安全的的 HTTP (譬如:HTTPS)。如果用戶登錄的憑證是通過明文在網絡傳輸的話,任何對 API 的保護措施是毫無意義的。
## 基于密碼的認證
現在我們假設存在一個資源通過一個 API 暴露給那些必須注冊的用戶。這個資源是通過 URL: /api/resource 能夠訪問到。
為了保護這個資源,我們將使用 HTTP 基本身份認證,但是不是自己編寫完整的代碼來實現它,而是讓 Flask-HTTPAuth 擴展來為我們做。
使用 Flask-HTTPAuth,通過添加 login_required 裝飾器可以要求相應的路由必須進行認證:
```
from flask.ext.httpauth import HTTPBasicAuth
auth = HTTPBasicAuth()
@app.route('/api/resource')
@auth.login_required
def get_resource():
return jsonify({ 'data': 'Hello, %s!' % g.user.username })
```
但是,Flask-HTTPAuth 需要給予更多的信息來驗證用戶的認證,當然 Flask-HTTPAuth 有著許多的選項,它取決于應用程序實現的安全級別。
能夠提供最大自由度的選擇(可能這也是唯一兼容 PassLib 散列)就是選用 verify_password 回調函數,這個回調函數將會根據提供的 username 和 password 的組合的,返回 True(通過驗證) 或者 Flase(未通過驗證)。Flask-HTTPAuth 將會在需要驗證 username 和 password 對的時候調用這個回調函數。
verify_password 回調函數的實現如下:
```
@auth.verify_password
def verify_password(username, password):
user = User.query.filter_by(username = username).first()
if not user or not user.verify_password(password):
return False
g.user = user
return True
```
這個函數將會根據 username 找到用戶,并且使用 verify_password() 方法驗證密碼。如果認證通過的話,用戶對象將會被存儲在 Flask 的 g 對象中,這樣視圖就能使用它。
這里是用 curl 請求只允許注冊用戶獲取的保護資源:
```
$ curl -u miguel:python -i -X GET http://127.0.0.1:5000/api/resource
HTTP/1.0 200 OK
Content-Type: application/json
Content-Length: 30
Server: Werkzeug/0.9.4 Python/2.7.3
Date: Thu, 28 Nov 2013 20:02:25 GMT
{
"data": "Hello, miguel!"
}
```
如果登錄失敗的話,會得到下面的內容:
```
$ curl -u miguel:ruby -i -X GET http://127.0.0.1:5000/api/resource
HTTP/1.0 401 UNAUTHORIZED
Content-Type: text/html; charset=utf-8
Content-Length: 19
WWW-Authenticate: Basic realm="Authentication Required"
Server: Werkzeug/0.9.4 Python/2.7.3
Date: Thu, 28 Nov 2013 20:03:18 GMT
Unauthorized Access
```
這里我再次重申在實際的應用中,請使用安全的 HTTP。
## 基于令牌的認證
每次請求必須發送 username 和 password 是十分不方便,即使是通過安全的 HTTP 傳輸的話還是存在風險,因為客戶端必須要存儲不加密的認證憑證,這樣才能在每次請求中發送。
一種基于之前解決方案的優化就是使用令牌來驗證請求。
我們的想法是客戶端應用程序使用認證憑證交換了認證令牌,接下來的請求只發送認證令牌。
令牌是具有有效時間,過了有效時間后,令牌變成無效,需要重新獲取新的令牌。令牌的潛在風險在于生成令牌的算法比較弱,但是有效期較短可以減少風險。
有很多的方法可以加強令牌。一個簡單的強化方式就是根據存儲在數據庫中的用戶以及密碼生成一個隨機的特定長度的字符串,可能過期日期也在里面。令牌就變成了明文密碼的重排,這樣就能很容易地進行字符串對比,還能對過期日期進行檢查。
更加完善的實現就是不需要服務器端進行任何存儲操作,使用加密的簽名作為令牌。這種方式有很多的優點,能夠根據用戶信息生成相關的簽名,并且很難被篡改。
Flask 使用類似的方式處理 cookies 的。這個實現依賴于一個叫做 itsdangerous 的庫,我們這里也會采用它。
令牌的生成以及驗證將會被添加到 User 模型中,其具體實現如下:
```
from itsdangerous import TimedJSONWebSignatureSerializer as Serializer
class User(db.Model):
# ...
def generate_auth_token(self, expiration = 600):
s = Serializer(app.config['SECRET_KEY'], expires_in = expiration)
return s.dumps({ 'id': self.id })
@staticmethod
def verify_auth_token(token):
s = Serializer(app.config['SECRET_KEY'])
try:
data = s.loads(token)
except SignatureExpired:
return None # valid token, but expired
except BadSignature:
return None # invalid token
user = User.query.get(data['id'])
return user
```
generate_auth_token() 方法生成一個以用戶 id 值為值,’id’ 為關鍵字的字典的加密令牌。令牌中同時加入了一個過期時間,默認為十分鐘(600 秒)。
驗證令牌是在 verify_auth_token() 靜態方法中實現的。靜態方法被使用在這里,是因為一旦令牌被解碼了用戶才可得知。如果令牌被解碼了,相應的用戶將會被查詢出來并且返回。
API 需要一個獲取令牌的新函數,這樣客戶端才能申請到令牌:
```
@app.route('/api/token')
@auth.login_required
def get_auth_token():
token = g.user.generate_auth_token()
return jsonify({ 'token': token.decode('ascii') })
```
注意:這個函數是使用了 auth.login_required 裝飾器,也就是說需要提供 username 和 password。
剩下來的就是決策客戶端怎樣在請求中包含這個令牌。
HTTP 基本認證方式不特別要求 usernames 和 passwords 用于認證,在 HTTP 頭中這兩個字段可以用于任何類型的認證信息。基于令牌的認證,令牌可以作為 username 字段,password 字段可以忽略。
這就意味著服務器需要同時處理 username 和 password 作為認證,以及令牌作為 username 的認證方式。verify_password 回調函數需要同時支持這兩種方式:
```
@auth.verify_password
def verify_password(username_or_token, password):
# first try to authenticate by token
user = User.verify_auth_token(username_or_token)
if not user:
# try to authenticate with username/password
user = User.query.filter_by(username = username_or_token).first()
if not user or not user.verify_password(password):
return False
g.user = user
return True
```
新版的 verify_password 回調函數會嘗試認證兩次。首先它會把 username 參數作為令牌進行認證。如果沒有驗證通過的話,就會像基于密碼認證的一樣,驗證 username 和 password。
如下的 curl 請求能夠獲取一個認證的令牌:
```
$ curl -u miguel:python -i -X GET http://127.0.0.1:5000/api/token
HTTP/1.0 200 OK
Content-Type: application/json
Content-Length: 139
Server: Werkzeug/0.9.4 Python/2.7.3
Date: Thu, 28 Nov 2013 20:04:15 GMT
{
"token": "eyJhbGciOiJIUzI1NiIsImV4cCI6MTM4NTY2OTY1NSwiaWF0IjoxMzg1NjY5MDU1fQ.eyJpZCI6MX0.XbOEFJkhjHJ5uRINh2JA1BPzXjSohKYDRT472wGOvjc"
}
```
現在可以使用令牌獲取資源:
```
$ curl -u eyJhbGciOiJIUzI1NiIsImV4cCI6MTM4NTY2OTY1NSwiaWF0IjoxMzg1NjY5MDU1fQ.eyJpZCI6MX0.XbOEFJkhjHJ5uRINh2JA1BPzXjSohKYDRT472wGOvjc:unused -i -X GET http://127.0.0.1:5000/api/resource
HTTP/1.0 200 OK
Content-Type: application/json
Content-Length: 30
Server: Werkzeug/0.9.4 Python/2.7.3
Date: Thu, 28 Nov 2013 20:05:08 GMT
{
"data": "Hello, miguel!"
}
```
需要注意的是這里并沒有使用密碼。
## OAuth 認證
當我們討論 RESTful 認證的時候,OAuth 協議經常被提及到。
那么什么是 OAuth?
OAuth 可以有很多的含義。最通常就是一個應用程序允許其它應用程序的用戶的接入或者使用服務,但是用戶必須使用應用程序提供的登錄憑證。我建議閱讀者可以瀏覽 [OAuth](http://en.wikipedia.org/wiki/OAuth) 了解更多知識。