建立部落格-使用REST Web服務
使用Flask建立REST Web服務很簡單,使用熟悉的route()
裝飾器及其methods
可選引數可以宣告服務所提供資源URL的路由,處理JSON資料同樣簡單,因為請求中包含的JSON資料可通過request.json
這個Python字典獲取,並且需要包含JSON的響應可以使用Flask提供的輔助函式jsonify()
從Python字典中生成
建立API藍本
REST API相關的路由是一個自成一體的程式子集,所以為了更好的組織程式碼,我們最好把這些路由放到獨立的藍本中,這個程式API藍本的基本結構如下:
|-flasky
|-app/
|-api_1_0
|-__init__.py
|-users.py
|-posts.py
|-comments.py
|-authentication.py
|-errors.py
|-decorators.py
注意,API包的名字中有個版本號,如果需要建立一個向前相容的API版本,可以新增一個版本號不同的包,讓程式同時支援兩個版本的API
在這個API藍本中,各資源分別在不同的模組中實現,藍本中還包含處理認證、錯誤以及提供自定義裝飾器的模組,藍本的構造檔案如下所示:
# app/api_1_0/__init__.py
from flask import Blueprint
api = Blueprint('api', __name__)
from . import authentication, posts, users, comments, errors
#...
註冊API藍本的程式碼如下:
# app/__init__.py
def create_app(config_name):
#...
from .api_1_0 import api as api_1_0_blueprint
app.register_blueprint(api_1_0_blueprint, url_prefix='/api/v1.0' )
return app
錯誤處理
REST Web服務將請求的狀態告知客戶端時,會在響應中傳送適當的HTTP狀態碼,並將額外資訊放入響應主體,客戶端能從Web服務得到的常見狀態碼如下表
HTTP狀態碼 | 名稱 | 說明 |
---|---|---|
200 | OK(成功) | 請求成功完成 |
201 | Created(已建立) | 請求成功完成並建立了一個新資源 |
400 | Bad request(壞請求) | 請求不可用或不一致 |
401 | Unauthorized(未授權) | 請求中為包含認證資訊 |
403 | Forbidden(禁止) | 請求中傳送的認證密令無權訪問目標 |
404 | Notfound(未找到) | URL對應資源不存在 |
405 | Methods not allowed(不允許使用的方法) | 指定資源不支援請求使用方法 |
500 | Internal server error(內部伺服器錯誤) | 處理請求的過程中發生意外錯誤 |
處理404和500狀態碼時會有點小麻煩,因為這兩個錯誤是由Flask自己生成的,而且一般會返回HTML響應,這很可能會讓API客戶端困惑
為所有客戶端生成適當相應的一種方式是,在錯誤處理程式中根據客戶端請求的格式改寫響應,這種技術成為內容協商, 下例是改進後的404錯誤處理程式,它向Web服務客戶端傳送JSON格式響應,除此之外都發送HTML格式響應,500錯誤處理程式的寫法類似
app/main/errors.py
@main.app_errorhandler(404)
def page_not_found(e):
if request.accept_mimetypes.accept_json and \
not request.accept_mimetypes.accept_html:
response = jsonify({'error': 'not found'})
response.status_code = 404
return response
return render_template('404.html'), 404
這個新版錯誤處理程式檢查Accept請求首部(Werkzeug將其編碼為request.accept_mimetypes
),根據首部的值決定客戶端期望接受的響應格式,瀏覽器一般不限制響應的格式,所以只為接受JSON格式而不接受HTML格式的客戶端生成JSON響應
其他狀態碼都是由Web服務生成,因此可在藍本的errors.py
模組作為輔助函式實現,下例是403錯誤的處理程式,其他錯誤處理程式的寫法類似
# app/api_1_0/errors.py
def forbidden(message):
response = jsonify({'error':'forbidden', 'message': message})
response.status_code = 403
return response
現在,Web服務的檢視函式可以呼叫這些輔助函式生成錯誤響應了
使用Flask-HTTPAuth認證使用者
和普通的Web程式一樣,Web服務也需要保護資訊,確保未經授權的使用者無法訪問,為此RIA必須詢問使用者的登入密令,並將其傳給伺服器進行驗證
REST Web服務的特徵之一是無狀態,即在伺服器在兩次請求之間不能“記住”客戶端的任何資訊,客戶端必須在發出的請求中包含所有必要資訊,因此所有請求都必須包含使用者密令
程式當前的登入功能是在Flask-Login的幫助下實現的,可以把資料儲存在使用者會話中,預設情況下,Flask把會話儲存在客戶端cookie中,因此伺服器沒有儲存任何使用者相關的資訊,都轉交給客戶端儲存,這種實現方式看起來遵守了REST架構的無狀態要求,但在REST Web服務中使用cookie有點不現實,因為Web瀏覽器之外的客戶端很難提供對cookie的支援,鑑於此,使用cookie並不是一個很好的設計選擇
REST架構的無狀態看起來似乎過於嚴格,但這並是不隨意提出的要求,無狀態的伺服器伸縮起來更加簡單,如果伺服器儲存了客戶端的相關資訊,就必須提供一個所有伺服器都能訪問的共享快取,這樣才能保證一直使用同一臺伺服器處理特定客戶端的請求,這樣的需求很難實現
因為REST架構基於HTTP協議,所以傳送密令的最佳方式是使用HTTP認證,基本認證和摘要認證都可以,在HTTP認證中,使用者密令包含在請求的Authorization首部中
HTTP認證協議很簡單,可以直接實現,不過Flask-HTTPAuth拓展提供了一個便利的包裝,可以把協議的細節隱藏在裝飾器之中,類似於Flask-Login提供的login_required裝飾器
Flask-HTTPAuth使用pip安裝,在將HTTP基本認證的擴充套件進行初始化之前,我們先要建立一個HTTPBasicAuth類物件,和Flask-Login一樣,Flask-HTTPAuth不對驗證使用者命令所需的步驟做任何假設,因此所需的資訊在回撥函式中提供,下例展示瞭如何初始化Flask-HTTPAuth擴充套件,以及如何在回撥函式中驗證密令
# app/api_1_0/authentication.py
from flask_httpauth import HTTPBasicAuth
auth = HTTPBasicAuth()
@auth.verify_password
def verify_password(email, password):
if email == '':
g.current_user = AnonymousUser()
return True
user = User.query.filter_by(email = email).first()
if not user:
return False
g.current_user = user
return user.verify_password(password)
由於這種使用者認證方法只在API藍本中使用,所以Flask-HTTPAuth擴充套件只能在藍本包中初始化,而不像其他擴充套件那樣要在程式包中初始化
電子郵件和密碼使用User模型中現有的方法驗證,如果登入密令正確,這個驗證回撥函式就返回True,否則返回False,API藍本也支援匿名使用者訪問,此時客戶端傳送的電子郵件欄位必須為空
驗證回撥函式把通過認證的使用者儲存在Flask的全域性物件g中,這樣一來,檢視函式便能進行訪問,注意在匿名登入時,這個函式返回True並把Flask-Login提供的AnonymousUser類例項賦值給g.current_user
由於每次請求時都要傳送使用者密令,所以API路由最好通過安全的HTTP提供,加密所有的請求和響應
如果認證密令不正確,伺服器向客戶端返回401錯誤,預設情況下,Flask-HTTPAuth自動生成這個狀態碼,但為了和API返回的其他錯誤保持一致,我們可以自定義這個錯誤響應:
#app/api_1_0/authentication.py
#...
@auth.error_headler
def auth_error():
return unauthorized('Invalid credentials')
為了保護路由,可使用裝飾器auth.login_required
@api.route('/posts')
@auth.login_required
def get_posts():
pass
不過,這個藍本中的所有路由都要使用相同的方式進行保護,所以我們可以在before_request
處理程式中使用一次login_required
裝飾器,應用到整個藍本,如下例所示:
#app/api_1_0/authentication.py
from .errors import forbidden
@api.before_request
@auth.login_required
def before_request():
if not g.current_user.is_anonymous and \
not g.current_user.comfirmed:
return forbidden('Uncofirmed account')
現在,API藍本中的所有路由都能進行自動認證,而且作為附加認證,before_request
處理程式還會拒絕已通過認證但沒有確認賬戶的使用者
基於令牌的認證
每次請求時,客戶端都要傳送認證密令,為了避免總是傳送敏感資訊,我們可以提供一種基於令牌的認證方案
使用基於令牌的認證方案時,客戶端要先把登入密令傳送給一個特殊的URL,從而生成認證令牌,一旦客戶端獲得令牌,就可用令牌代替登入密令認證請求,處於安全考慮,令牌有過期時間,令牌過期後,客戶端必須重新發送登陸密令以生成新令牌,令牌落入他人之手所帶來的安全隱患受限於令牌的短暫使用期限,為了生成和驗證認證令牌,我們要在User模型中定義兩個新方法,這兩個新方法用到了itsdangerous
包,如下
# app/models.py
class User(db.Model):
#....
def generate_auth_token(self, expiration):
s = Serializer(current_app.config['SECRET_KEY'],
expires_in=expiration)
return s.dumps({'id': self.id})
@staticmethod
def verify_auth_token(token):
s = Serializer(current_app.config['SECRET_KEY'])
try:
data = s.loads(token)
except:
return None
return User.query.get(data['id'])
generate_auth_token()
方法使用編碼後的使用者id欄位值生成一個簽名令牌,還指定了以秒為單位的過期時間,verify_auth_token()
方法接受的引數是一個令牌,如果令牌可用就返回對應的物件,verify_auth_token()
是靜態方法,因為只有解碼令牌後才能知道使用者是誰
為了能夠認證包含令牌的請求,我們必須修改Flask-HTTPAuth提供的verify_password
回撥,除了普通的密令之外,還要接受令牌,修改後的回撥函式如下:
# app/api_1_0/authentication.py
@auth.verify_password
def verify_password(email_or_token, password):
if email_or_token == '':
g.current_user = AnonymousUser()
return True
if password == '':
g.current_user = User.verify_auth_token(email_or_token)
g.token_used = True
return g.current_user is not None
user = User.query.filter_by(email = email_or_token).first()
if not user:
return False
g.current_user = user
g.token_used = False
return user.verify_password(password)
在這個新版本中,第一個認證引數可以是電子郵件地址或認證令牌,如果這個引數為空,那就和之前一樣,假定是匿名使用者,如果密碼為空,那就假定email_or_token
引數提供的是令牌,按照令牌的方式進行認證,如果兩個引數都不為空,假定使用常規的郵件地址和密碼進行認證,在這種實現方式中,基於令牌的認證是可選的,由客戶端決定是否使用,為了讓檢視函式能區分這兩種認證方式,我們添加了g.token_used
變數
把認證令牌傳送給客戶端的路由也要新增到API藍本中,具體實現如下:
# app/api_1_0/authentication.py
#...
@api.route('/token')
def get_token():
if g.current_user.is_anonymous() or g.token_used:
return unauthorized("Invalid credentials")
return jsonify({'token': g.current_user.generate_auth_token(
expiration=3600), 'expiration': 3600})
這個路由也在藍本中,所以新增到before_request
處理程式上的認證機制也會用在這個路由上,為了避免客戶端使用舊令牌申請新令牌,要在檢視函式中檢查g.token_used
變數的值,如果使用令牌進行認證就拒絕請求,這個檢視函式返回JSON格式的響應,其中包含了過期時間為1小時的令牌,JSON格式的響應也包含過期時間