Flask教程第二十三章:應用程式程式設計介面(API)

天降攻城獅發表於2019-02-23

本文轉載自:https://www.jianshu.com/p/6088c36f2c88

我為此應用程式構建的所有功能都只適用於特定型別的客戶端:Web瀏覽器。 但其他型別的客戶端呢? 例如,如果我想構建Android或iOS APP,有兩種主流方法可以解決這個問題。 最簡單的解決方案是構建一個簡單的APP,僅使用一個Web檢視元件並用Microblog網站填充整個螢幕,但相比在裝置的Web瀏覽器中開啟網站,這種方案几乎沒有什麼賣點。 一個更好的解決方案(儘管更費力)將是構建一個本地APP,但這個APP如何與僅返回HTML頁面的伺服器互動呢?

這就是應用程式程式設計介面(API)的能力範疇了。 API是一組HTTP路由,被設計為應用程式中的低階入口點。與定義返回HTML以供Web瀏覽器使用的路由和檢視函式不同,API允許客戶端直接使用應用程式的資源,從而決定如何通過客戶端完全地向使用者呈現資訊。 例如,Microblog中的API可以向使用者提供使用者資訊和使用者動態,並且它還可以允許使用者編輯現有動態,但僅限於資料級別,不會將此邏輯與HTML混合。

如果你研究了應用程式中當前定義的所有路由,會注意到其中的幾個符合我上面使用的API的定義。 找到它們了嗎? 我說的是返回JSON的幾條路由,比如第十四章中定義的/translate路由。 這種路由的內容都以JSON格式編碼,並在請求時使用POST方法。 此請求的響應也是JSON格式,伺服器僅返回所請求的資訊,客戶端負責將此資訊呈現給使用者。

雖然應用程式中的JSON路由具有API的“感覺”,但它們的設計初衷是為支援在瀏覽器中執行的Web應用程式。 設想一下,如果智慧手機APP想要使用這些路由,它將無法使用,因為這需要使用者登入,而登入只能通過HTML表單進行。 在本章中,我將展示如何構建不依賴於Web瀏覽器的API,並且不會假設連線到它們的客戶端的型別。

本章的GitHub連結為:BrowseZipDiff.

REST API設計風格

REST as a Foundation of API Design

有些人可能會強烈反對上面提到的/translate和其他JSON路由是API路由。 其他人可能會同意,但也會認為它們是一個設計糟糕的API。 那麼一個精心設計的API有什麼特點,為什麼上面的JSON路由不是一個好的API路由呢?

你可能聽說過REST API。 REST(Representational State Transfer)是Roy Fielding在博士論文中提出的一種架構。 該架構中,Dr. Fielding以相當抽象和通用的方式展示了REST的六個定義特徵。

除了Dr.Fielding的論文外,沒有關於REST的權威性規範,從而留下了許多細節供讀者解讀。 一個給定的API是否符合REST規範的話題往往是REST“純粹主義者”之間激烈爭論的源頭,REST“純粹主義者”認為REST API必須以非常明確的方式遵循全部六個特徵,而不像REST“實用主義者”那樣,僅僅將Dr. Fielding在論文中提出的想法作為指導原則或建議。Dr.Fielding站在純粹主義陣營的一邊,並在部落格文章和線上評論中的撰寫了一些額外的見解來表達他的願景。

目前實施的絕大多數API都遵循“實用主義”的REST實現。 包括來自Facebook,GitHub,Twitter等“大玩家”的大部分API都是如此。很少有公共API被一致認為是純REST,因為大多數API都沒有包含純粹主義者認為必須實現的某些細節。 儘管Dr. Fielding和其他REST純粹主義者對評判一個API是否是REST API有嚴格的規定,但軟體行業在實際運用中引用REST是很常見的。

為了讓你瞭解REST論文中的內容,以下各節將介紹Dr. Fielding列舉的六項原則。

客戶端-伺服器

客戶端-伺服器原則相當簡單,正如其字面含義,在REST API中,客戶端和伺服器的角色應該明確區分。 在實踐中,這意味著客戶端和伺服器都是單獨的程式,並在大多數情況下,使用基於TCP網路上的HTTP協議進行通訊。

分層系統

分層系統原則是說當客戶端需要與伺服器通訊時,它可能最終連線到代理伺服器而不是實際的伺服器。 因此,對於客戶端來說,如果不直接連線到伺服器,它傳送請求的方式應該沒有什麼區別,事實上,它甚至可能不知道它是否連線到目標伺服器。 同樣,這個原則規定伺服器相容直接接收來自代理伺服器的請求,所以它絕不能假設連線的另一端一定是客戶端。

這是REST的一個重要特性,因為能夠新增中間節點的這個特性,允許應用程式架構師使用負載均衡器,快取,代理伺服器等來設計滿足大量請求的大型複雜網路。

快取

該原則擴充套件了分層系統,通過明確指出允許伺服器或代理伺服器快取頻繁且相同請求的響應內容以提高系統效能。 有一個你可能熟悉的快取實現:所有Web瀏覽器中的快取。 Web瀏覽器快取層通常用於避免一遍又一遍地請求相同的檔案,例如影像。

為了達到API的目的,目標伺服器需要通過使用快取控制來指示響應是否可以在代理伺服器傳回客戶端時進行快取。 請注意,由於安全原因,部署到生產環境的API必須使用加密,因此,除非此代理伺服器terminates SSL連線,或者執行解密和重新加密,否則快取通常不會在代理伺服器中完成。

按需獲取客戶端程式碼(Code On Demand)

這是一項可選要求,規定伺服器可以提供可執行程式碼以響應客戶端,這樣一來,就可以從伺服器上獲取客戶端的新功能。 因為這個原則需要伺服器和客戶端之間就客戶端能夠執行的可執行程式碼型別達成一致,所以這在API中很少使用。 你可能會認為伺服器可能會返回JavaScript程式碼以供Web瀏覽器客戶端執行,但REST並非專門針對Web瀏覽器客戶端而設計。 例如,如果客戶端是iOS或Android裝置,執行JavaScript可能會帶來一些複雜情況。

無狀態

無狀態原則是REST純粹主義者和實用主義者之間爭論最多的兩個中心之一。 它指出,REST API不應儲存客戶端傳送請求時的任何狀態。 這意味著,在Web開發中常見的機制都不能在使用者瀏覽應用程式頁面時“記住”使用者。 在無狀態API中,每個請求都需要包含伺服器需要識別和驗證客戶端並執行請求的資訊。這也意味著伺服器無法在資料庫或其他儲存形式中儲存與客戶端連線有關的任何資料。

如果你想知道為什麼REST需要無狀態伺服器,主要原因是無狀態伺服器非常容易擴充套件,你只需在負載均衡器後面執行多個伺服器例項即可。 如果伺服器儲存客戶端狀態,則事情會變得更復雜,因為你必須弄清楚多個伺服器如何訪問和更新該狀態,或者確保給定客戶端始終由同一伺服器處理,這樣的機制通常稱為粘性會話

再思考一下本章介紹中討論的/translate路由,就會發現它不能被視為RESTful,因為與該路由相關的檢視函式依賴於Flask-Login的@login_required裝飾器, 這會將使用者的登入狀態儲存在Flask使用者會話中。

統一介面

最後,最重要的,最有爭議的,最含糊不清的REST原則是統一介面。 Dr. Fielding列舉了REST統一介面的四個特性:唯一資源識別符號,資源表示,自描述性訊息和超媒體。

唯一資源識別符號是通過為每個資源分配唯一的URL來實現的。 例如,與給定使用者關聯的URL可以是/api/users/,其中是在資料庫表主鍵中分配給使用者的識別符號。 大多數API都能很好地實現這一點。

資源表示的使用意味著當伺服器和客戶端交換關於資源的資訊時,他們必須使用商定的格式。 對於大多數現代API,JSON格式用於構建資源表示。 API可以選擇支援多種資源表示格式,並且在這種情況下,HTTP協議中的內容協商選項是客戶端和伺服器確認格式的機制。

自描述性訊息意味著在客戶端和伺服器之間交換的請求和響應必須包含對方需要的所有資訊。 作為一個典型的例子,HTTP請求方法用於指示客戶端希望伺服器執行的操作。 GET請求表示客戶想要檢索資源資訊,POST請求表示客戶想要建立新資源,PUTPATCH請求定義對現有資源的修改,DELETE表示刪除資源的請求。 目標資源被指定為請求的URL,並在HTTP頭,URL的查詢字串部分或請求主體中提供附加資訊。

超媒體需求是最具爭議性的,而且很少有API實現,而那些實現它的API很少以滿足REST純粹主義者的方式進行。由於應用程式中的資源都是相互關聯的,因此此要求會要求將這些關係包含在資源表示中,以便客戶端可以通過遍歷關係來發現新資源,這幾乎與你在Web應用程式中通過點選從一個頁面到另一個頁面的連結來發現新頁面的方式相同。理想情況下,客戶端可以輸入一個API,而不需要任何有關其中的資源的資訊,就可以簡單地通過超媒體連結來了解它們。但是,與HTML和XML不同,通常用於API中資源表示的JSON格式沒有定義包含連結的標準方式,因此你不得不使用自定義結構,或者類似JSON-APIHAL JSON-LD這樣的試圖解決這種差距的JSON擴充套件之一。

實現API Blueprint

為了讓你體驗開發API所涉及的內容,我將在Microblog新增API。 我不會實現所有的API,只會實現與使用者相關的所有功能,並將其他資源(如使用者動態)的實現留給讀者作為練習。

為了保持組織有序,並遵循我在第十五章中描述的結構, 我將建立一個包含所有API路由的新blueprint。 所以,讓我們從建立blueprint所在的目錄開始:

(venv) $ mkdir app/api

在blueprint的__init__.py檔案中建立blueprint物件,這與應用程式中的其他blueprint類似:

app/api/__init__.py: API blueprint 構造器。

from flask import Blueprint

bp = Blueprint(`api`, __name__)

from app.api import users, errors, tokens

你可能會記得有時需要將匯入移動到底部以避免迴圈依賴錯誤。 這就是為什麼app/api/users.pyapp/api/errors.pyapp/api/tokens.py模組(我還沒有寫)在blueprint建立之後匯入的原因。

API的主要內容將儲存在app/api/users.py模組中。 下表總結了我要實現的路由:

HTTP 方法 資源 URL 註釋
GET /api/users/ 返回一個使用者
GET /api/users 返回所有使用者的集合
GET /api/users//followers 返回某個使用者的粉絲集合
GET /api/users//followed 返回某個使用者關注的使用者集合
POST /api/users 註冊一個新使用者
PUT /api/users/ 修改某個使用者

現在我要建立一個模組的框架,其中使用佔位符來暫時填充所有的路由:

app/api/users.py:使用者API資源佔位符。

from app.api import bp

@bp.route(`/users/<int:id>`, methods=[`GET`])
def get_user(id):
    pass

@bp.route(`/users`, methods=[`GET`])
def get_users():
    pass

@bp.route(`/users/<int:id>/followers`, methods=[`GET`])
def get_followers(id):
    pass

@bp.route(`/users/<int:id>/followed`, methods=[`GET`])
def get_followed(id):
    pass

@bp.route(`/users`, methods=[`POST`])
def create_user():
    pass

@bp.route(`/users/<int:id>`, methods=[`PUT`])
def update_user(id):
    pass

app/api/errors.py模組將定義一些處理錯誤響應的輔助函式。 但現在,我使用佔位符,並將在之後填充內容:

app/api/errors.py:錯誤處理佔位符。

def bad_request():
    pass

app/api/tokens.py是將要定義認證子系統的模組。 它將為非Web瀏覽器登入的客戶端提供另一種方式。現在,我也使用佔位符來處理該模組:

app/api/tokens.py: Token處理佔位符。

def get_token():
    pass

def revoke_token():
    pass

新的API blueprint需要在應用工廠函式中註冊:

app/__init__.py:應用中註冊API blueprint。

# ...

def create_app(config_class=Config):
    app = Flask(__name__)

    # ...

    from app.api import bp as api_bp
    app.register_blueprint(api_bp, url_prefix=`/api`)

    # ...

將使用者表示為JSON物件

實施API時要考慮的第一個方面是決定其資源表示形式。 我要實現一個使用者型別的API,因此我需要決定的是使用者資源的表示形式。 經過一番頭腦風暴,得出了以下JSON表示形式:

{
    "id": 123,
    "username": "susan",
    "password": "my-password",
    "email": "susan@example.com",
    "last_seen": "2017-10-20T15:04:27Z",
    "about_me": "Hello, my name is Susan!",
    "post_count": 7,
    "follower_count": 35,
    "followed_count": 21,
    "_links": {
        "self": "/api/users/123",
        "followers": "/api/users/123/followers",
        "followed": "/api/users/123/followed",
        "avatar": "https://www.gravatar.com/avatar/..."
    }
}

許多欄位直接來自使用者資料庫模型。 password欄位的特殊之處在於,它僅在註冊新使用者時才會使用。 回顧第五章,使用者密碼不儲存在資料庫中,只儲存一個雜湊字串,所以密碼永遠不會被返回。email欄位也被專門處理,因為我不想公開使用者的電子郵件地址。 只有當使用者請求自己的條目時,才會返回email欄位,但是當他們檢索其他使用者的條目時不會返回。post_countfollower_countfollowed_count欄位是“虛擬”欄位,它們在資料庫欄位中不存在,提供給客戶端是為了方便。 這是一個很好的例子,它演示了資源表示不需要和伺服器中資源的實際定義一致。

請注意_links部分,它實現了超媒體要求。 定義的連結包括指向當前資源的連結,使用者的粉絲列表連結,使用者關注的使用者列表連結,最後是指向使用者頭像影像的連結。 將來,如果我決定向這個API新增使用者動態,那麼使用者的動態列表連結也應包含在這裡。

JSON格式的一個好處是,它總是轉換為Python字典或列表的表示形式。 Python標準庫中的json包負責Python資料結構和JSON之間的轉換。因此,為了生成這些表示,我將在User模型中新增一個名為to_dict()的方法,該方法返回一個Python字典:

app/models.py:User模型轉換成表示。

from flask import url_for
# ...

class User(UserMixin, db.Model):
    # ...

    def to_dict(self, include_email=False):
        data = {
            `id`: self.id,
            `username`: self.username,
            `last_seen`: self.last_seen.isoformat() + `Z`,
            `about_me`: self.about_me,
            `post_count`: self.posts.count(),
            `follower_count`: self.followers.count(),
            `followed_count`: self.followed.count(),
            `_links`: {
                `self`: url_for(`api.get_user`, id=self.id),
                `followers`: url_for(`api.get_followers`, id=self.id),
                `followed`: url_for(`api.get_followed`, id=self.id),
                `avatar`: self.avatar(128)
            }
        }
        if include_email:
            data[`email`] = self.email
        return data

該方法一目瞭然,只是簡單地生成並返回使用者表示的字典。正如我上面提到的那樣,email欄位需要特殊處理,因為我只想在使用者請求自己的資料時才包含電子郵件。 所以我使用include_email標誌來確定該欄位是否包含在表示中。

注意一下last_seen欄位的生成。 對於日期和時間欄位,我將使用ISO 8601格式,Python的datetime物件可以通過isoformat()方法生成這樣格式的字串。 但是因為我使用的datetime物件的時區是UTC,且但沒有在其狀態中記錄時區,所以我需要在末尾新增Z,即ISO 8601的UTC時區程式碼。

最後,看看我如何實現超媒體連結。 對於指向應用其他路由的三個連結,我使用url_for()生成URL(目前指向我在app/api/users.py中定義的佔位符檢視函式)。 頭像連結是特殊的,因為它是應用外部的Gravatar URL。 對於這個連結,我使用了與渲染網頁中的頭像的相同avatar()方法。

to_dict()方法將使用者物件轉換為Python表示,以後會被轉換為JSON。 我還需要其反向處理的方法,即客戶端在請求中傳遞使用者表示,伺服器需要解析並將其轉換為User物件。 以下是實現從Python字典到User物件轉換的from_dict()方法:

app/models.py:表示轉換成User模型。

class User(UserMixin, db.Model):
    # ...

    def from_dict(self, data, new_user=False):
        for field in [`username`, `email`, `about_me`]:
            if field in data:
                setattr(self, field, data[field])
        if new_user and `password` in data:
            self.set_password(data[`password`])

本處我決定使用迴圈來匯入客戶端可以設定的任何欄位,即usernameemailabout_me。 對於每個欄位,我檢查它是否存在於data引數中,如果存在,我使用Python的setattr()在物件的相應屬性中設定新值。

password欄位被視為特例,因為它不是物件中的欄位。 new_user引數確定了這是否是新的使用者註冊,這意味著data中包含password。 要在使用者模型中設定密碼,需要呼叫set_password()方法來建立密碼雜湊。

表示使用者集合

除了使用單個資源表示形式外,此API還需要一組使用者的表示。 例如客戶請求使用者或粉絲列表時使用的格式。 以下是一組使用者的表示:

{
    "items": [
        { ... user resource ... },
        { ... user resource ... },
        ...
    ],
    "_meta": {
        "page": 1,
        "per_page": 10,
        "total_pages": 20,
        "total_items": 195
    },
    "_links": {
        "self": "http://localhost:5000/api/users?page=1",
        "next": "http://localhost:5000/api/users?page=2",
        "prev": null
    }
}

在這個表示中,items是使用者資源的列表,每個使用者資源的定義如前一節所述。 _meta部分包含集合的後設資料,客戶端在向使用者渲染分頁控制元件時就會用得上。 _links部分定義了相關連結,包括集合本身的連結以及上一頁和下一頁連結,也能幫助客戶端對列表進行分頁。

由於分頁邏輯,生成使用者集合的表示很棘手,但是該邏輯對於我將來可能要新增到此API的其他資源來說是一致的,所以我將以通用的方式實現它,以便適用於其他模型。 可以回顧第十六章,就會發現我目前的情況與全文索引類似,都是實現一個功能,還要讓它可以應用於任何模型。 對於全文索引,我使用的解決方案是實現一個SearchableMixin類,任何需要全文索引的模型都可以從中繼承。 我會故技重施,實現一個新的mixin類,我命名為PaginatedAPIMixin

app/models.py:分頁表示mixin類。

class PaginatedAPIMixin(object):
    @staticmethod
    def to_collection_dict(query, page, per_page, endpoint, **kwargs):
        resources = query.paginate(page, per_page, False)
        data = {
            `items`: [item.to_dict() for item in resources.items],
            `_meta`: {
                `page`: page,
                `per_page`: per_page,
                `total_pages`: resources.pages,
                `total_items`: resources.total
            },
            `_links`: {
                `self`: url_for(endpoint, page=page, per_page=per_page,
                                **kwargs),
                `next`: url_for(endpoint, page=page + 1, per_page=per_page,
                                **kwargs) if resources.has_next else None,
                `prev`: url_for(endpoint, page=page - 1, per_page=per_page,
                                **kwargs) if resources.has_prev else None
            }
        }
        return data

to_collection_dict()方法產生一個帶有使用者集合表示的字典,包括items_meta_links部分。 你可能需要仔細檢查該方法以瞭解其工作原理。 前三個引數是Flask-SQLAlchemy查詢物件,頁碼和每頁資料數量。 這些是決定要返回的條目是什麼的引數。 該實現使用查詢物件的paginate()方法來獲取該頁的條目,就像我對主頁,發現頁和個人主頁中的使用者動態所做的一樣。

複雜的部分是生成連結,其中包括自引用以及指向下一頁和上一頁的連結。 我想讓這個函式具有通用性,所以我不能使用類似url_for(`api.get_users`, id=id, page=page)這樣的程式碼來生成自連結(譯者注:因為這樣就固定成使用者資源專用了)。 url_for()的引數將取決於特定的資源集合,所以我將依賴於呼叫者在endpoint引數中傳遞的值,來確定需要傳送到url_for()的檢視函式。 由於許多路由都需要引數,我還需要在kwargs中捕獲更多關鍵字引數,並將它們傳遞給url_for()。 pageper_page查詢字串引數是明確給出的,因為它們控制所有API路由的分頁。

這個mixin類需要作為父類新增到User模型中:

app/models.py:新增PaginatedAPIMixin到User模型中。

class User(PaginatedAPIMixin, UserMixin, db.Model):
    # ...

將集合轉換成json表示,不需要反向操作,因為我不需要客戶端傳送使用者列表到伺服器。

錯誤處理

我在第七章中定義的錯誤頁面僅適用於使用Web瀏覽器的使用者。當一個API需要返回一個錯誤時,它需要是一個“機器友好”的錯誤型別,以便客戶端可以輕鬆解釋這些錯誤。 因此,我同樣設計錯誤的表示為一個JSON。 以下是我要使用的基本結構:

{
    "error": "short error description",
    "message": "error message (optional)"
}

除了錯誤的有效載荷之外,我還會使用HTTP協議的狀態程式碼來指示常見錯誤的型別。 為了幫助我生成這些錯誤響應,我將在app/api/errors.py中寫入error_response()函式:

app/api/errors.py:錯誤響應。

from flask import jsonify
from werkzeug.http import HTTP_STATUS_CODES

def error_response(status_code, message=None):
    payload = {`error`: HTTP_STATUS_CODES.get(status_code, `Unknown error`)}
    if message:
        payload[`message`] = message
    response = jsonify(payload)
    response.status_code = status_code
    return response

該函式使用來自Werkzeug(Flask的核心依賴項)的HTTP_STATUS_CODES字典,它為每個HTTP狀態程式碼提供一個簡短的描述性名稱。 我在錯誤表示中使用這些名稱作為error欄位的值,所以我只需要操心數字狀態碼和可選的長描述。 jsonify()函式返回一個預設狀態碼為200的FlaskResponse物件,因此在建立響應之後,我將狀態碼設定為對應的錯誤程式碼。

API將返回的最常見錯誤將是程式碼400,代表了“錯誤的請求”。 這是客戶端傳送請求中包含無效資料的錯誤。 為了更容易產生這個錯誤,我將為它新增一個專用函式,只需傳入長的描述性訊息作為引數就可以呼叫。 下面是我之前新增的bad_request()佔位符:

app/api/errors.py:錯誤請求的響應。

# ...

def bad_request(message):
    return error_response(400, message)

使用者資源Endpoint

必需的使用者JSON表示的支援已完成,因此我已準備好開始對API endpoint進行編碼了。

檢索單個使用者

讓我們就從使用給定的id來檢索指定使用者開始吧:

app/api/users.py:返回一個使用者。

from flask import jsonify
from app.models import User

@bp.route(`/users/<int:id>`, methods=[`GET`])
def get_user(id):
    return jsonify(User.query.get_or_404(id).to_dict())

檢視函式接收被請求使用者的id作為URL中的動態引數。 查詢物件的get_or_404()方法是以前見過的get()方法的一個非常有用的變體,如果使用者存在,它返回給定id的物件,當id不存在時,它會中止請求並向客戶端返回一個404錯誤,而不是返回None。 get_or_404()get()更有優勢,它不需要檢查查詢結果,簡化了檢視函式中的邏輯。

我新增到User的to_dict()方法用於生成使用者資源表示的字典,然後Flask的jsonify()函式將該字典轉換為JSON格式的響應以返回給客戶端。

如果你想檢視第一條API路由的工作原理,請啟動伺服器,然後在瀏覽器的位址列中輸入以下URL:

http://localhost:5000/api/users/1

瀏覽器會以JSON格式顯示第一個使用者。 也嘗試使用大一些的id值來檢視SQLAlchemy查詢物件的get_or_404()方法如何觸發404錯誤(我將在稍後向你演示如何擴充套件錯誤處理,以便返回這些錯誤 JSON格式)。

為了測試這條新路由,我將安裝HTTPie,這是一個用Python編寫的命令列HTTP客戶端,可以輕鬆傳送API請求:

(venv) $ pip install httpie

我現在可以請求id1的使用者(可能是你自己),命令如下:

(venv) $ http GET http://localhost:5000/api/users/1
HTTP/1.0 200 OK
Content-Length: 457
Content-Type: application/json
Date: Mon, 27 Nov 2017 20:19:01 GMT
Server: Werkzeug/0.12.2 Python/3.6.3

{
    "_links": {
        "avatar": "https://www.gravatar.com/avatar/993c...2724?d=identicon&s=128",
        "followed": "/api/users/1/followed",
        "followers": "/api/users/1/followers",
        "self": "/api/users/1"
    },
    "about_me": "Hello! I`m the author of the Flask Mega-Tutorial.",
    "followed_count": 0,
    "follower_count": 1,
    "id": 1,
    "last_seen": "2017-11-26T07:40:52.942865Z",
    "post_count": 10,
    "username": "miguel"
}

檢索使用者集合

要返回所有使用者的集合,我現在可以依靠PaginatedAPIMixinto_collection_dict()方法:

app/api/users.py:返回所有使用者的集合。

from flask import request

@bp.route(`/users`, methods=[`GET`])
def get_users():
    page = request.args.get(`page`, 1, type=int)
    per_page = min(request.args.get(`per_page`, 10, type=int), 100)
    data = User.to_collection_dict(User.query, page, per_page, `api.get_users`)
    return jsonify(data)

對於這個實現,我首先從請求的查詢字串中提取pageper_page,如果它們沒有被定義,則分別使用預設值1和10。 per_page具有額外的邏輯,以100為上限。 給客戶端控制元件請求太大的頁面並不是一個好主意,因為這可能會導致伺服器的效能問題。 然後pageper_page以及query物件(在本例中,該查詢只是User.query,是返回所有使用者的最通用的查詢)引數被傳遞給to_collection_query()方法。 最後一個引數是api.get_users,這是我在表示中使用的三個連結所需的endpoint名稱。

要使用HTTPie測試此endpoint,請使用以下命令:

(venv) $ http GET http://localhost:5000/api/users

接下來的兩個endpoint是返回粉絲集合和關注使用者集合。 與上面的非常相似:

app/api/users.py:返回粉絲列表和關注使用者列表。

@bp.route(`/users/<int:id>/followers`, methods=[`GET`])
def get_followers(id):
    user = User.query.get_or_404(id)
    page = request.args.get(`page`, 1, type=int)
    per_page = min(request.args.get(`per_page`, 10, type=int), 100)
    data = User.to_collection_dict(user.followers, page, per_page,
                                   `api.get_followers`, id=id)
    return jsonify(data)

@bp.route(`/users/<int:id>/followed`, methods=[`GET`])
def get_followed(id):
    user = User.query.get_or_404(id)
    page = request.args.get(`page`, 1, type=int)
    per_page = min(request.args.get(`per_page`, 10, type=int), 100)
    data = User.to_collection_dict(user.followed, page, per_page,
                                   `api.get_followed`, id=id)
    return jsonify(data)

由於這兩條路由是特定於使用者的,因此它們具有id動態引數。 id用於從資料庫中獲取使用者,然後將user.followersuser.followed關係查詢提供給to_collection_dict(),所以希望現在你可以看到,花費一點點額外的時間,並以通用的方式設計該方法,對於獲得的回報而言是值得的。 to_collection_dict()的最後兩個引數是endpoint名稱和idid將在kwargs中作為一個額外關鍵字引數,然後在生成連結時將它傳遞給url_for() 。

和前面的示例類似,你可以使用HTTPie來測試這兩個路由,如下所示:

(venv) $ http GET http://localhost:5000/api/users/1/followers
(venv) $ http GET http://localhost:5000/api/users/1/followed

由於超媒體,你不需要記住這些URL,因為它們包含在使用者表示的_links部分。

註冊新使用者

/users路由的POST請求將用於註冊新的使用者帳戶。 你可以在下面看到這條路由的實現:

app/api/users.py:註冊新使用者。

from flask import url_for
from app import db
from app.api.errors import bad_request

@bp.route(`/users`, methods=[`POST`])
def create_user():
    data = request.get_json() or {}
    if `username` not in data or `email` not in data or `password` not in data:
        return bad_request(`must include username, email and password fields`)
    if User.query.filter_by(username=data[`username`]).first():
        return bad_request(`please use a different username`)
    if User.query.filter_by(email=data[`email`]).first():
        return bad_request(`please use a different email address`)
    user = User()
    user.from_dict(data, new_user=True)
    db.session.add(user)
    db.session.commit()
    response = jsonify(user.to_dict())
    response.status_code = 201
    response.headers[`Location`] = url_for(`api.get_user`, id=user.id)
    return response

該請求將接受請求主體中提供的來自客戶端的JSON格式的使用者表示。 Flask提供request.get_json()方法從請求中提取JSON並將其作為Python結構返回。 如果在請求中沒有找到JSON資料,該方法返回None,所以我可以使用表示式request.get_json() or {}確保我總是可以獲得一個字典。

在我可以使用這些資料之前,我需要確保我已經掌握了所有資訊,因此我首先檢查是否包含三個必填欄位,username, emailpassword。 如果其中任何一個缺失,那麼我使用app/api/errors.py模組中的bad_request()輔助函式向客戶端返回一個錯誤。 除此之外,我還需要確保usernameemail欄位尚未被其他使用者使用,因此我嘗試使用獲得的使用者名稱和電子郵件從資料庫中載入使用者,如果返回了有效的使用者,那麼我也將返回錯誤給客戶端。

一旦通過了資料驗證,我可以輕鬆建立一個使用者物件並將其新增到資料庫中。 為了建立使用者,我依賴User模型中的from_dict()方法,new_user引數被設定為True,所以它也接受通常不存在於使用者表示中的password欄位。

我為這個請求返回的響應將是新使用者的表示,所以使用to_dict()產生它的有效載荷。 建立資源的POST請求的響應狀態程式碼應該是201,即建立新實體時使用的程式碼。 此外,HTTP協議要求201響應包含一個值為新資源URL的Location頭部。

下面你可以看到如何通過HTTPie從命令列註冊一個新使用者:

(venv) $ http POST http://localhost:5000/api/users username=alice password=dog 
    email=alice@example.com "about_me=Hello, my name is Alice!"

編輯使用者

示例API中使用的最後一個endpoint用於修改已存在的使用者:

app/api/users.py:修改使用者。

@bp.route(`/users/<int:id>`, methods=[`PUT`])
def update_user(id):
    user = User.query.get_or_404(id)
    data = request.get_json() or {}
    if `username` in data and data[`username`] != user.username and 
            User.query.filter_by(username=data[`username`]).first():
        return bad_request(`please use a different username`)
    if `email` in data and data[`email`] != user.email and 
            User.query.filter_by(email=data[`email`]).first():
        return bad_request(`please use a different email address`)
    user.from_dict(data, new_user=False)
    db.session.commit()
    return jsonify(user.to_dict())

一個請求到來,我通過URL收到一個動態的使用者id,所以我可以載入指定的使用者或返回404錯誤(如果找不到)。 就像註冊新使用者一樣,我需要驗證客戶端提供的usernameemail欄位是否與其他使用者發生了衝突,但在這種情況下,驗證有點棘手。 首先,這些欄位在此請求中是可選的,所以我需要檢查欄位是否存在。 第二個複雜因素是客戶端可能提供與目前欄位相同的值,所以在檢查使用者名稱或電子郵件是否被採用之前,我需要確保它們與當前的不同。 如果任何驗證檢查失敗,那麼我會像之前一樣返回400錯誤給客戶端。

一旦資料驗證通過,我可以使用User模型的from_dict()方法匯入客戶端提供的所有資料,然後將更改提交到資料庫。 該請求的響應會將更新後的使用者表示返回給使用者,並使用預設的200狀態程式碼。

以下是一個示例請求,它用HTTPie編輯about_me欄位:

(venv) $ http PUT http://localhost:5000/api/users/2 "about_me=Hi, I am Miguel"

API 認證

我在前一節中新增的API endpoint當前對任何客戶端都是開放的。 顯然,執行這些操作需要認證使用者才安全,為此我需要新增認證授權,簡稱“AuthN”和“AuthZ”。 思路是,客戶端傳送的請求提供了某種標識,以便伺服器知道客戶端代表的是哪位使用者,並且可以驗證是否允許該使用者執行請求的操作。

保護這些API endpoint的最明顯的方法是使用Flask-Login中的@login_required裝飾器,但是這種方法存在一些問題。 裝飾器檢測到未通過身份驗證的使用者時,會將使用者重定向到HTML登入頁面。 在API中沒有HTML或登入頁面的概念,如果客戶端傳送帶有無效或缺少憑證的請求,伺服器必須拒絕請求並返回401狀態碼。 伺服器不能假定API客戶端是Web瀏覽器,或者它可以處理重定向,或者它可以渲染和處理HTML登入表單。 當API客戶端收到401狀態碼時,它知道它需要向使用者詢問憑證,但是它是如何實現的,伺服器不需要關心。

User模型中實現Token

對於API身份驗證需求,我將使用token身份驗證方案。 當客戶端想要開始與API互動時,它需要使用使用者名稱和密碼進行驗證,然後獲得一個臨時token。 只要token有效,客戶端就可以傳送附帶token的API請求以通過認證。 一旦token到期,需要請求新的token。 為了支援使用者token,我將擴充套件User模型:

app/models.py:支援使用者token。

import base64
from datetime import datetime, timedelta
import os

class User(UserMixin, PaginatedAPIMixin, db.Model):
    # ...
    token = db.Column(db.String(32), index=True, unique=True)
    token_expiration = db.Column(db.DateTime)

    # ...

    def get_token(self, expires_in=3600):
        now = datetime.utcnow()
        if self.token and self.token_expiration > now + timedelta(seconds=60):
            return self.token
        self.token = base64.b64encode(os.urandom(24)).decode(`utf-8`)
        self.token_expiration = now + timedelta(seconds=expires_in)
        db.session.add(self)
        return self.token

    def revoke_token(self):
        self.token_expiration = datetime.utcnow() - timedelta(seconds=1)

    @staticmethod
    def check_token(token):
        user = User.query.filter_by(token=token).first()
        if user is None or user.token_expiration < datetime.utcnow():
            return None
        return user

我為使用者模型新增了一個token屬性,並且因為我需要通過它搜尋資料庫,所以我為它設定了唯一性和索引。 我還新增了token_expiration欄位,它儲存token過期的日期和時間。 這使得token不會長時間有效,以免成為安全風險。

我建立了三種方法來處理這些token。 get_token()方法為使用者返回一個token。 以base64編碼的24位隨機字串來生成這個token,以便所有字元都處於可讀字串範圍內。 在建立新token之前,此方法會檢查當前分配的token在到期之前是否至少還剩一分鐘,並且在這種情況下會返回現有的token。

使用token時,有一個策略可以立即使token失效總是一件好事,而不是僅依賴到期日期。 這是一個經常被忽視的安全最佳實踐。 revoke_token()方法使得當前分配給使用者的token失效,只需設定到期時間為當前時間的前一秒。

check_token()方法是一個靜態方法,它將一個token作為引數傳入並返回此token所屬的使用者。 如果token無效或過期,則該方法返回None

由於我對資料庫進行了更改,因此需要生成新的資料庫遷移,然後使用它升級資料庫:

(venv) $ flask db migrate -m "user tokens"
(venv) $ flask db upgrade

帶Token的請求

當你編寫一個API時,你必須考慮到你的客戶端並不總是要連線到Web應用程式的Web瀏覽器。 當獨立客戶端(如智慧手機APP)甚至是基於瀏覽器的單頁應用程式訪問後端服務時,API展示力量的機會就來了。 當這些專用客戶端需要訪問API服務時,他們首先需要請求token,對應傳統Web應用程式中登入表單的部分。

為了簡化使用token認證時客戶端和伺服器之間的互動,我將使用名為Flask-HTTPAuth的Flask外掛。 Flask-HTTPAuth可以使用pip安裝:

(venv) $ pip install flask-httpauth

Flask-HTTPAuth支援幾種不同的認證機制,都對API友好。 首先,我將使用HTTPBasic Authentication,該機制要求客戶端在標準的Authorization頭部中附帶使用者憑證。 要與Flask-HTTPAuth整合,應用需要提供兩個函式:一個用於檢查使用者提供的使用者名稱和密碼,另一個用於在認證失敗的情況下返回錯誤響應。這些函式通過裝飾器在Flask-HTTPAuth中註冊,然後在認證流程中根據需要由外掛自動呼叫。 實現如下:

app/api/auth.py:基本認證支援。

from flask import g
from flask_httpauth import HTTPBasicAuth
from app.models import User
from app.api.errors import error_response

basic_auth = HTTPBasicAuth()

@basic_auth.verify_password
def verify_password(username, password):
    user = User.query.filter_by(username=username).first()
    if user is None:
        return False
    g.current_user = user
    return user.check_password(password)

@basic_auth.error_handler
def basic_auth_error():
    return error_response(401)

Flask-HTTPAuth的HTTPBasicAuth類實現了基本的認證流程。 這兩個必需的函式分別通過verify_passworderror_handler裝飾器進行註冊。

驗證函式接收客戶端提供的使用者名稱和密碼,如果憑證有效則返回True,否則返回False。 我依賴User類的check_password()方法來檢查密碼,它在Web應用的認證過程中,也會被Flask-Login使用。 我將認證使用者儲存在g.current_user中,以便我可以從API檢視函式中訪問它。

錯誤處理函式只返回由app/api/errors.py模組中的error_response()函式生成的401錯誤。 401錯誤在HTTP標準中定義為“未授權”錯誤。 HTTP客戶端知道當它們收到這個錯誤時,需要重新傳送有效的憑證。

現在我已經實現了基本認證的支援,因此我可以新增一條token檢索路由,以便客戶端在需要token時呼叫:

app/api/tokens.py:生成使用者token。

from flask import jsonify, g
from app import db
from app.api import bp
from app.api.auth import basic_auth

@bp.route(`/tokens`, methods=[`POST`])
@basic_auth.login_required
def get_token():
    token = g.current_user.get_token()
    db.session.commit()
    return jsonify({`token`: token})

這個檢視函式使用了HTTPBasicAuth例項中的@basic_auth.login_required裝飾器,它將指示Flask-HTTPAuth驗證身份(通過我上面定義的驗證函式),並且僅當提供的憑證是有效的才執行下面的檢視函式。 該檢視函式的實現依賴於使用者模型的get_token()方法來生成token。 資料庫提交在生成token後發出,以確保token及其到期時間被寫回到資料庫。

如果你嘗試直接向token API路由傳送POST請求,則會發生以下情況:

(venv) $ http POST http://localhost:5000/api/tokens
HTTP/1.0 401 UNAUTHORIZED
Content-Length: 30
Content-Type: application/json
Date: Mon, 27 Nov 2017 20:01:00 GMT
Server: Werkzeug/0.12.2 Python/3.6.3
WWW-Authenticate: Basic realm="Authentication Required"

{
    "error": "Unauthorized"
}

HTTP響應包括401狀態碼和我在basic_auth_error()函式中定義的錯誤負載。 下面請求帶上了基本認證需要的憑證:

(venv) $ http --auth <username>:<password> POST http://localhost:5000/api/tokens
HTTP/1.0 200 OK
Content-Length: 50
Content-Type: application/json
Date: Mon, 27 Nov 2017 20:01:22 GMT
Server: Werkzeug/0.12.2 Python/3.6.3

{
    "token": "pC1Nu9wwyNt8VCj1trWilFdFI276AcbS"
}

現在狀態碼是200,這是成功請求的程式碼,並且有效載荷包括使用者的token。 請注意,當你傳送這個請求時,你需要用你自己的憑證來替換<username>:<password>。 使用者名稱和密碼需要以冒號作為分隔符。

使用Token機制保護API路由

客戶端現在可以請求一個token來和API endpoint一起使用,所以剩下的就是向這些endpoint新增token驗證。 Flask-HTTPAuth也可以為我處理的這些事情。 我需要建立基於HTTPTokenAuth類的第二個身份驗證例項,並提供token驗證回撥:

app/api/auth.py: Token認證支援。

# ...
from flask_httpauth import HTTPTokenAuth

# ...
token_auth = HTTPTokenAuth()

# ...

@token_auth.verify_token
def verify_token(token):
    g.current_user = User.check_token(token) if token else None
    return g.current_user is not None

@token_auth.error_handler
def token_auth_error():
    return error_response(401)

使用token認證時,Flask-HTTPAuth使用的是verify_token裝飾器註冊驗證函式,除此之外,token認證的工作方式與基本認證相同。 我的token驗證函式使用User.check_token()來定位token所屬的使用者。 該函式還通過將當前使用者設定為None來處理缺失token的情況。返回值是True還是False,決定了Flask-HTTPAuth是否允許檢視函式的執行。

為了使用token保護API路由,需要新增@token_auth.login_required裝飾器:

app/api/users.py:使用token認證保護使用者路由。

from app.api.auth import token_auth

@bp.route(`/users/<int:id>`, methods=[`GET`])
@token_auth.login_required
def get_user(id):
    # ...

@bp.route(`/users`, methods=[`GET`])
@token_auth.login_required
def get_users():
    # ...

@bp.route(`/users/<int:id>/followers`, methods=[`GET`])
@token_auth.login_required
def get_followers(id):
    # ...

@bp.route(`/users/<int:id>/followed`, methods=[`GET`])
@token_auth.login_required
def get_followed(id):
    # ...

@bp.route(`/users`, methods=[`POST`])
def create_user():
    # ...

@bp.route(`/users/<int:id>`, methods=[`PUT`])
@token_auth.login_required
def update_user(id):
    # ...

請注意,裝飾器被新增到除create_user()之外的所有API檢視函式中,顯而易見,這個函式不能使用token認證,因為使用者都不存在時,更不會有token了。

如果你直接對上面列出的受token保護的endpoint發起請求,則會得到一個401錯誤。為了成功訪問,你需要新增Authorization頭部,其值是請求/api/tokens獲得的token的值。Flask-HTTPAuth期望的是”不記名”token,但是它沒有被HTTPie直接支援。就像針對基本認證,HTTPie提供了--auth選項來接受使用者名稱和密碼,但是token的頭部則需要顯式地提供了。下面是傳送不記名token的格式:

(venv) $ http GET http://localhost:5000/api/users/1 
    "Authorization:Bearer pC1Nu9wwyNt8VCj1trWilFdFI276AcbS"

撤銷Token

我將要實現的最後一個token相關功能是token撤銷,如下所示:

app/api/tokens.py:撤銷token。

from app.api.auth import token_auth

@bp.route(`/tokens`, methods=[`DELETE`])
@token_auth.login_required
def revoke_token():
    g.current_user.revoke_token()
    db.session.commit()
    return ``, 204

客戶端可以向/tokens URL傳送DELETE請求,以使token失效。此路由的身份驗證是基於token的,事實上,在Authorization頭部中傳送的token就是需要被撤銷的。撤銷使用了User類中的輔助方法,該方法重新設定token過期日期來實現撤銷操作。之後提交資料庫會話,以確保將更改寫入資料庫。這個請求的響應沒有正文,所以我可以返回一個空字串。Return語句中的第二個值設定狀態程式碼為204,該程式碼用於成功請求卻沒有響應主體的響應。

下面是撤銷token的一個HTTPie請求示例:

(venv) $ http DELETE http://localhost:5000/api/tokens 
    Authorization:"Bearer pC1Nu9wwyNt8VCj1trWilFdFI276AcbS"

API友好的錯誤訊息

你是否還記得,在本章的前部分,當我要求你用一個無效的使用者URL從瀏覽器傳送一個API請求時發生了什麼?伺服器返回了404錯誤,但是這個錯誤被格式化為標準的404 HTML錯誤頁面。在API blueprint中的API可能返回的許多錯誤可以被重寫為JSON版本,但是仍然有一些錯誤是由Flask處理的,處理這些錯誤的處理函式是被全域性註冊到應用中的,返回的是HTML。

HTTP協議支援一種機制,通過該機制,客戶機和伺服器可以就響應的最佳格式達成一致,稱為內容協商。客戶端需要傳送一個Accept頭部,指示格式首選項。然後,伺服器檢視自身格式列表並使用匹配客戶端格式列表中的最佳格式進行響應。

我想做的是修改全域性應用的錯誤處理器,使它們能夠根據客戶端的格式首選項對返回內容是使用HTML還是JSON進行內容協商。這可以通過使用Flask的request.accept_mimetypes來完成:

app/errors/handlers.py:為錯誤響應進行內容協商。

from flask import render_template, request
from app import db
from app.errors import bp
from app.api.errors import error_response as api_error_response

def wants_json_response():
    return request.accept_mimetypes[`application/json`] >= 
        request.accept_mimetypes[`text/html`]

@bp.app_errorhandler(404)
def not_found_error(error):
    if wants_json_response():
        return api_error_response(404)
    return render_template(`errors/404.html`), 404

@bp.app_errorhandler(500)
def internal_error(error):
    db.session.rollback()
    if wants_json_response():
        return api_error_response(500)
    return render_template(`errors/500.html`), 500

wants_json_response()輔助函式比較客戶端對JSON和HTML格式的偏好程度。 如果JSON比HTML高,那麼我會返回一個JSON響應。 否則,我會返回原始的基於模板的HTML響應。 對於JSON響應,我將使用從API blueprint中匯入error_response輔助函式,但在這裡我要將其重新命名為api_error_response(),以便清楚它的作用和來歷。


相關文章