1. 程式人生 > >建立部落格-使用REST Web服務

建立部落格-使用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格式的響應也包含過期時間