Flask 教程 第二十三章:應用程序編程接口(API)

本文轉載自: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": "[email protected]",
    "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 protected] "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(),以便清楚它的作用和來歷。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章