flask 應用程式編程介面(API)最後一節

  • 2019 年 12 月 10 日
  • 筆記

REST作為API設計的基礎

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

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

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

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

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

客戶端-伺服器

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

分級系統

分級系統原則是說當客戶端需要與伺服器通訊時,它可能最終連接到代理伺服器而不是實際的伺服器。因此,對於客戶端來說,如果不直接連接到伺服器,它發送請求的方式應該沒有同樣,這個原則規定伺服器兼容直接接收來自代理伺服器的請求,所以它絕不能假設連接的另一端一定是客戶端

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

快取

該原則擴展了分級系統,通過明確指出了允許伺服器或代理伺服器快取重新同步並且相同請求的響應內容以提高系統性能。有一個您可能熟悉的快取實現:所有Web瀏覽器中的快取。層通常為避免一遍又一遍地請求相同的文件,例如影像。

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

按需獲取客戶端程式碼(按需編碼)

這是一項可選要求,規定伺服器可以提供基於伺服器的程式碼以響應客戶端,這樣一來,就可以從伺服器上獲取客戶端的新功能。因為這個原則需要伺服器和客戶端之間就可以客戶端能夠運行您可能會認為伺服器可能會返回JavaScript程式碼以供Web瀏覽器客戶端執行,但REST非專門針對Web瀏覽器客戶端而設計。例如,如果客戶端是iOS或Android設備,執行JavaScript可能會帶來一些複雜情況

無狀態

它指出,REST API可以保存客戶端發送請求時的任何狀態。這意味著,在Web開發中常見的在無狀態API中,每個請求都需要包含伺服器需要識別和驗證客戶端並執行請求的資訊。這也意味著伺服器無法在資料庫或其他存儲形式中存儲與客戶端連接有關的任何數據。

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

再思考一下本章介紹中討論的/翻譯路由,就會發現它不能被認為是RESTful的,因為與該路由相關的視圖函數依賴於Flask-Login的@login_required裝飾器,這可以將用戶的登錄狀態存儲在Flaskk用戶上會話中

統一介面

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

例如,與給定用戶關聯的URL可以是/ api / users / <user-id>,其中<user-id>是在資料庫表主鍵中分配給用戶的標識符。多數API全部很好地實現這一點。

對於大多數現代API,JSON格式用於生成資源表示。API可以選擇支援多種資源表示格式,並且在這種情況下,HTTP協議中的內容協商選項是客戶端和伺服器確認格式的機制。

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

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

實現API藍圖

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

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

(venv) $ mkdir app/api  

在blueprint的__init__.py文件中創建blueprint對象,這與應用程式中的其他blueprint類似:

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

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方法

資源網址

注釋

GET

/ api / users / <id>

返回一個用戶

GET

/ api /用戶

返回所有用戶的集合

GET

/ api / users / <id> /關注者

返回某個用戶的粉絲集合

GET

/ api / users / <id> /跟隨

返回某個用戶關注的用戶集合

POST

/ api /用戶

註冊一個新用戶

PUT

/ api / users / <id>

修改某個用戶

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

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:令牌處理佔位符。

def get_token():      pass    def revoke_token():      pass  

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

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

# ...    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,因此我需要決定的是用戶資源的表示形式。經過一番頭腦風暴,產生了以下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:用戶模型轉換成表示。

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:表示轉換成用戶模型。

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()方法來創建³³密碼哈希。

表示用戶集合

例如使用客戶請求用戶或粉絲列表時使用的格式。以下是一組用戶的表示:

{      "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部分。你可能需要仔細檢查該方法以了解其工作原理。前三個參數是燒瓶SQLAlchemy的查詢對象,頁碼和每頁數據數量。這些是決定該實現使用查詢對象的paginate()方法來獲取該頁的壓縮,就像我對主頁,發現頁和個人主頁中的用戶動態維護的一樣。

我想讓這個函數具有通用性,所以我不能使用類似url_for('api.get_users', id=id, page=page)這樣的程式碼來生成自鏈接(譯者註:因為這樣的固定固定成用戶資源專用了)。url_for()的參數將相應的特定資源集合,所以我將依賴於調用者在endpoint參數中傳遞的值,來確定需要發送到url_for()的視圖函數。由於許多路由都需要參數,我還需要在kwargs中捕獲更多的關鍵字參數,它們相互傳遞給url_for()pageper_page查詢字元串參數是明確的通知,因為它們控制所有API路由的分頁。

這個mixin類需要作為父類添加到UserModel中:

app / models.py:添加PaginatedAPIMixin到UserModel中。

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

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

錯誤處理

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

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

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

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的瓶Response對象,因此在創建響應之後,我將狀態碼設置為對應的錯誤程式碼。

API將返回的最常見錯誤將是程式碼400,代表了「錯誤的請求」。這是客戶端發送請求中包含無效數據的錯誤。為了更容易產生這個錯誤,我將為它添加一個專用函數,只以下是我之前添加的bad_request()佔位符:

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

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

用戶資源端點

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

檢索個別用戶

讓我們就從使用給定的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()改變優勢,它不需要檢查查詢結果,簡化了視圖函數中的邏輯。

我添加到用戶的to_dict()方法用於生成用戶資源表示的字典,然後Flask的jsonify()函數啟動字典轉換為JSON格式的響應以返回給客戶端。

如果您想查看第一條API路由的工作原理,請啟動伺服器,然後在瀏覽器的地址重定向輸入以下URL:

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

也可以嘗試使用大一些的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以及查詢對象(在本例中,該查詢只是User.query,是返回所有用戶的最通用的查詢)參數被傳遞給to_collection_query()方法。是api.get_users,這是我在表示中使用的三個鏈接所需的端點名稱。

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

(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()的最後兩個參數是端點名稱和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格式的用戶表示request.get_json()。Flask 提供方法從請求中提取JSON並以其作為Python結構返回。如果在請求中沒有找到JSON數據,該方法返回None,那麼我可以使用表達式request.get_json() or {}確保我總是可以獲得一個字典。

在我可以使用這些數據之前,我需要確保我已經掌握了所有資訊,因此我首先檢查是否包含三個必填欄位,usernameemailpassword。如果其中任何一個缺失,那麼我使用應用程式/ 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中使用的最後一個端點用於修改已存在的用戶:

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認證

觀察,執行這些操作需要認證用戶才安全,從而我需要添加認證授權,簡稱「 AuthN」和「 AuthZ」。是,客戶端發送的請求提供了某種標識,盔甲伺服器知道客戶端代表的是哪位用戶,並且可以驗證是否允許該用戶執行請求的操作。

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

用戶模型中實現令牌

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

app / models.py:支援用戶令牌。

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替換,它保存了令牌過期的日期和時間。時間有效,以免成為安全風險。

我為它創建了一種方法來處理這些令牌。get_token()方法為用戶返回一個令牌。以base64編碼的24位隨機字元串來生成這個令牌,將所有字元都置於串聯範圍內。在創建新令牌之前,此方法會檢查當前分配的令牌在過期之前是否至少還剩一分鐘,並且在這種情況下會返回現有的令牌。

使用令牌時,有一個策略可以立即使令牌失效總是總是一件好事,而不是僅依賴終止日期。這是一個經常被替代的安全最佳實踐。revoke_token()方法始終將其分配給令牌的令牌,只需設置終止時間為當前時間的前一秒。

check_token()方法是一個靜態方法,將一個令牌作為參數重置並返回此令牌所屬的用戶。如果令牌無效或過期,則該方法返回None

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

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

帶令牌的請求

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

為了簡化使用令牌認證時客戶端和伺服器之間的交互,我將使用Flask-HTTPAuth的Flask插件。Flask-HTTPAuth可以使用pip安裝:

(venv) $ pip install flask-httpauth  

首先,我將使用HTTP基本認證,該機制要求客戶端在標準的授權中中附帶用戶憑證。要與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客戶端知道當它們收到此錯誤時,需要重新發送有效的憑證。

現在我已經實現了基本認證的支援,因此我可以添加一條令牌檢索路由,剎車客戶端在需要令牌時調用:

app / api / tokens.py:生成用戶令牌。

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()方法來生成令牌。資料庫提交在生成令牌後發出,以確保令牌及其到期時間被寫回到資料庫。

如果您嘗試直接向令牌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,這是成功請求的程式碼,並且有效糾正包括用戶的令牌。請注意,當你發送這個請求時,你需要用你自己的憑證來替換<username>:<password>。用戶名和密碼需要以冒號作為分隔符。

使用令牌機制保護API路由

客戶端現在可以請求一個令牌來和API端點一起使用,所以剩下的就是向這些端點添加令牌驗證。Flask-HTTPAuth也可以為我處理的這些事情。我需要創建基於HTTPTokenAuth類的第二個身份驗證實例,並提供令牌驗證替代:

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

# ...  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)  

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

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

app / api / users.py:使用令牌認證保護用戶路由。

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視圖函數中,例如,此函數不能使用令牌認證,因為用戶都不存在時,更不會有令牌了。

如果您直接對上面列出的受令牌保護的端點發起請求,導致得到一個401錯誤。為了成功訪問,您需要添加Authorization標題,其值是請求/ api / tokens獲得的令牌的值。Flask-HTTPAuth期望的是「不記名」令牌,但是它沒有被HTTPie直接支援。就像針對基本認證,HTTPie提供了--auth選項來接受用戶名和密碼,但是令牌的令牌則需要顯式地提供了。下面是發送不記名令牌的格式:

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

撤銷令牌

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

app / api / tokens.py恢復令牌。

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  

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

下面是取消token的一個HTTPie請求示例:

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

API友好的錯誤消息

你是否還記得,在本章的前部分,當我要求你用一個無效的用戶URL從瀏覽器發送一個API請求時發生了什麼?伺服器返回了404錯誤,但是這個錯誤被格式化為標準的404 HTML錯誤頁面。在API藍圖中的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響應,我將使用從API blueprint中引入error_response輔助函數,但在這裡我要將其重命名為api_error_response(),剎車清楚它的作用和來歷