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

本文翻譯自The Flask Mega-Tutorial Part XXIII: Application Programming Interfaces (APIs)html

我爲此應用程序構建的全部功能都只適用於特定類型的客戶端:Web瀏覽器。 但其餘類型的客戶端呢? 例如,若是我想構建Android或iOS APP,有兩種主流方法能夠解決這個問題。 最簡單的解決方案是構建一個簡單的APP,僅使用一個Web視圖組件並用Microblog網站填充整個屏幕,但相比在設備的Web瀏覽器中打開網站,這種方案几乎沒有什麼賣點。 一個更好的解決方案(儘管更費力)將是構建一個本地APP,但這個APP如何與僅返回HTML頁面的服務器交互呢?git

這就是應用程序編程接口(API)的能力範疇了。 API是一組HTTP路由,被設計爲應用程序中的低級入口點。與定義返回HTML以供Web瀏覽器使用的路由和視圖函數不一樣,API容許客戶端直接使用應用程序的資源,從而決定如何經過客戶端徹底地向用戶呈現信息。 例如,Microblog中的API能夠向用戶提供用戶信息和用戶動態,而且它還能夠容許用戶編輯現有動態,但僅限於數據級別,不會將此邏輯與HTML混合。github

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

雖然應用程序中的JSON路由具備API的「感受」,但它們的設計初衷是爲支持在瀏覽器中運行的Web應用程序。 設想一下,若是智能手機APP想要使用這些路由,它將沒法使用,由於這須要用戶登陸,而登陸只能經過HTML表單進行。 在本章中,我將展現如何構建不依賴於Web瀏覽器的API,而且不會假設鏈接到它們的客戶端的類型。編程

本章的GitHub連接爲:BrowseZipDiff.json

REST API設計風格

REST as a Foundation of API Design

有些人可能會強烈反對上面提到的/translate和其餘JSON路由是API路由。 其餘人可能會贊成,但也會認爲它們是一個設計糟糕的API。 那麼一個精心設計的API有什麼特色,爲何上面的JSON路由不是一個好的API路由呢?flask

你可能據說過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

目前實施的絕大多數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所在的目錄開始:

1 (venv) $ mkdir app/api

在blueprint的__init__.py文件中建立blueprint對象,這與應用程序中的其餘blueprint相似:

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

1 from flask import Blueprint
2 
3 bp = Blueprint('api', __name__)
4 
5 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資源佔位符。

 1 from app.api import bp
 2 
 3 @bp.route('/users/<int:id>', methods=['GET'])
 4 def get_user(id):
 5     pass
 6 
 7 @bp.route('/users', methods=['GET'])
 8 def get_users():
 9     pass
10 
11 @bp.route('/users/<int:id>/followers', methods=['GET'])
12 def get_followers(id):
13     pass
14 
15 @bp.route('/users/<int:id>/followed', methods=['GET'])
16 def get_followed(id):
17     pass
18 
19 @bp.route('/users', methods=['POST'])
20 def create_user():
21     pass
22 
23 @bp.route('/users/<int:id>', methods=['PUT'])
24 def update_user(id):
25     pass

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

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

1 def bad_request():
2     pass

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

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

1 def get_token():
2     pass
3 
4 def revoke_token():
5     pass

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

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

 1 # ...
 2 
 3 def create_app(config_class=Config):
 4     app = Flask(__name__)
 5 
 6     # ...
 7 
 8     from app.api import bp as api_bp
 9     app.register_blueprint(api_bp, url_prefix='/api')
10 
11     # ...

將用戶表示爲JSON對象

實施API時要考慮的第一個方面是決定其資源表示形式。 我要實現一個用戶類型的API,所以我須要決定的是用戶資源的表示形式。 通過一番頭腦風暴,得出瞭如下JSON表示形式:

 1 {
 2     "id": 123,
 3     "username": "susan",
 4     "password": "my-password",
 5     "email": "susan@example.com",
 6     "last_seen": "2017-10-20T15:04:27Z",
 7     "about_me": "Hello, my name is Susan!",
 8     "post_count": 7,
 9     "follower_count": 35,
10     "followed_count": 21,
11     "_links": {
12         "self": "/api/users/123",
13         "followers": "/api/users/123/followers",
14         "followed": "/api/users/123/followed",
15         "avatar": "https://www.gravatar.com/avatar/..."
16     }
17 }

許多字段直接來自用戶數據庫模型。 password字段的特殊之處在於,它僅在註冊新用戶時纔會使用。 回顧第五章,用戶密碼不存儲在數據庫中,只存儲一個散列字符串,因此密碼永遠不會被返回。email字段也被專門處理,由於我不想公開用戶的電子郵件地址。 只有當用戶請求本身的條目時,纔會返回email字段,可是當他們檢索其餘用戶的條目時不會返回。post_countfollower_countfollowed_count字段是「虛擬」字段,它們在數據庫字段中不存在,提供給客戶端是爲了方便。 這是一個很好的例子,它演示了資源表示不須要和服務器中資源的實際定義一致。

請注意_links部分,它實現了超媒體要求。 定義的連接包括指向當前資源的連接,用戶的粉絲列表連接,用戶關注的用戶列表連接,最後是指向用戶頭像圖像的連接。 未來,若是我決定向這個API添加用戶動態,那麼用戶的動態列表連接也應包含在這裏。

JSON格式的一個好處是,它老是轉換爲Python字典或列表的表示形式。 Python標準庫中的json包負責Python數據結構和JSON之間的轉換。所以,爲了生成這些表示,我將在User模型中添加一個名爲to_dict()的方法,該方法返回一個Python字典:

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

 1 from flask import url_for
 2 # ...
 3 
 4 class User(UserMixin, db.Model):
 5     # ...
 6 
 7     def to_dict(self, include_email=False):
 8         data = {
 9             'id': self.id,
10             'username': self.username,
11             'last_seen': self.last_seen.isoformat() + 'Z',
12             'about_me': self.about_me,
13             'post_count': self.posts.count(),
14             'follower_count': self.followers.count(),
15             'followed_count': self.followed.count(),
16             '_links': {
17                 'self': url_for('api.get_user', id=self.id),
18                 'followers': url_for('api.get_followers', id=self.id),
19                 'followed': url_for('api.get_followed', id=self.id),
20                 'avatar': self.avatar(128)
21             }
22         }
23         if include_email:
24             data['email'] = self.email
25         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模型。

1 class User(UserMixin, db.Model):
2     # ...
3 
4     def from_dict(self, data, new_user=False):
5         for field in ['username', 'email', 'about_me']:
6             if field in data:
7                 setattr(self, field, data[field])
8         if new_user and 'password' in data:
9             self.set_password(data['password'])

本處我決定使用循環來導入客戶端能夠設置的任何字段,即usernameemailabout_me。 對於每一個字段,我檢查它是否存在於data參數中,若是存在,我使用Python的setattr()在對象的相應屬性中設置新值。

password字段被視爲特例,由於它不是對象中的字段。 new_user參數肯定了這是不是新的用戶註冊,這意味着data中包含password。 要在用戶模型中設置密碼,須要調用set_password()方法來建立密碼哈希。

表示用戶集合

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

 1 {
 2     "items": [
 3         { ... user resource ... },
 4         { ... user resource ... },
 5         ...
 6     ],
 7     "_meta": {
 8         "page": 1,
 9         "per_page": 10,
10         "total_pages": 20,
11         "total_items": 195
12     },
13     "_links": {
14         "self": "http://localhost:5000/api/users?page=1",
15         "next": "http://localhost:5000/api/users?page=2",
16         "prev": null
17     }
18 }

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

因爲分頁邏輯,生成用戶集合的表示很棘手,可是該邏輯對於我未來可能要添加到此API的其餘資源來講是一致的,因此我將以通用的方式實現它,以便適用於其餘模型。 能夠回顧第十六章,就會發現我目前的狀況與全文索引相似,都是實現一個功能,還要讓它能夠應用於任何模型。 對於全文索引,我使用的解決方案是實現一個SearchableMixin類,任何須要全文索引的模型均可以從中繼承。 我會故技重施,實現一個新的mixin類,我命名爲PaginatedAPIMixin

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

 1 class PaginatedAPIMixin(object):
 2     @staticmethod
 3     def to_collection_dict(query, page, per_page, endpoint, **kwargs):
 4         resources = query.paginate(page, per_page, False)
 5         data = {
 6             'items': [item.to_dict() for item in resources.items],
 7             '_meta': {
 8                 'page': page,
 9                 'per_page': per_page,
10                 'total_pages': resources.pages,
11                 'total_items': resources.total
12             },
13             '_links': {
14                 'self': url_for(endpoint, page=page, per_page=per_page,
15                                 **kwargs),
16                 'next': url_for(endpoint, page=page + 1, per_page=per_page,
17                                 **kwargs) if resources.has_next else None,
18                 'prev': url_for(endpoint, page=page - 1, per_page=per_page,
19                                 **kwargs) if resources.has_prev else None
20             }
21         }
22         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模型中。

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

將集合轉換成json表示,不須要反向操做,由於我不須要客戶端發送用戶列表到服務器。

錯誤處理

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

1 {
2     "error": "short error description",
3     "message": "error message (optional)"
4 }

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

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

 1 from flask import jsonify
 2 from werkzeug.http import HTTP_STATUS_CODES
 3 
 4 def error_response(status_code, message=None):
 5     payload = {'error': HTTP_STATUS_CODES.get(status_code, 'Unknown error')}
 6     if message:
 7         payload['message'] = message
 8     response = jsonify(payload)
 9     response.status_code = status_code
10     return response

該函數使用來自Werkzeug(Flask的核心依賴項)的HTTP_STATUS_CODES字典,它爲每一個HTTP狀態代碼提供一個簡短的描述性名稱。 我在錯誤表示中使用這些名稱做爲error字段的值,因此我只須要操心數字狀態碼和可選的長描述。 jsonify()函數返回一個默認狀態碼爲200的FlaskResponse對象,所以在建立響應以後,我將狀態碼設置爲對應的錯誤代碼。

API將返回的最多見錯誤將是代碼400,表明了「錯誤的請求」。 這是客戶端發送請求中包含無效數據的錯誤。 爲了更容易產生這個錯誤,我將爲它添加一個專用函數,只需傳入長的描述性消息做爲參數就能夠調用。 下面是我以前添加的bad_request()佔位符:

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

1 # ...
2 
3 def bad_request(message):
4     return error_response(400, message)

用戶資源Endpoint

必需的用戶JSON表示的支持已完成,所以我已準備好開始對API endpoint進行編碼了。

檢索單個用戶

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

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

1 from flask import jsonify
2 from app.models import User
3 
4 @bp.route('/users/<int:id>', methods=['GET'])
5 def get_user(id):
6     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:

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

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

爲了測試這條新路由,我將安裝HTTPie,這是一個用Python編寫的命令行HTTP客戶端,能夠輕鬆發送API請求:

(venv) $ pip install httpie

我如今能夠請求id1的用戶(多是你本身),命令以下:

 1 (venv) $ http GET http://localhost:5000/api/users/1
 2 HTTP/1.0 200 OK
 3 Content-Length: 457
 4 Content-Type: application/json
 5 Date: Mon, 27 Nov 2017 20:19:01 GMT
 6 Server: Werkzeug/0.12.2 Python/3.6.3
 7 
 8 {
 9     "_links": {
10         "avatar": "https://www.gravatar.com/avatar/993c...2724?d=identicon&s=128",
11         "followed": "/api/users/1/followed",
12         "followers": "/api/users/1/followers",
13         "self": "/api/users/1"
14     },
15     "about_me": "Hello! I'm the author of the Flask Mega-Tutorial.",
16     "followed_count": 0,
17     "follower_count": 1,
18     "id": 1,
19     "last_seen": "2017-11-26T07:40:52.942865Z",
20     "post_count": 10,
21     "username": "miguel"
22 }

檢索用戶集合

要返回全部用戶的集合,我如今能夠依靠PaginatedAPIMixinto_collection_dict()方法:

app/api/users.py:返回全部用戶的集合。

1 from flask import request
2 
3 @bp.route('/users', methods=['GET'])
4 def get_users():
5     page = request.args.get('page', 1, type=int)
6     per_page = min(request.args.get('per_page', 10, type=int), 100)
7     data = User.to_collection_dict(User.query, page, per_page, 'api.get_users')
8     return jsonify(data)

對於這個實現,我首先從請求的查詢字符串中提取pageper_page,若是它們沒有被定義,則分別使用默認值1和10。 per_page具備額外的邏輯,以100爲上限。 給客戶端控件請求太大的頁面並非一個好主意,由於這可能會致使服務器的性能問題。 而後pageper_page以及query對象(在本例中,該查詢只是User.query,是返回全部用戶的最通用的查詢)參數被傳遞給to_collection_query()方法。 最後一個參數是api.get_users,這是我在表示中使用的三個連接所需的endpoint名稱。

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

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

接下來的兩個endpoint是返回粉絲集合和關注用戶集合。 與上面的很是類似:

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

 1 @bp.route('/users/<int:id>/followers', methods=['GET'])
 2 def get_followers(id):
 3     user = User.query.get_or_404(id)
 4     page = request.args.get('page', 1, type=int)
 5     per_page = min(request.args.get('per_page', 10, type=int), 100)
 6     data = User.to_collection_dict(user.followers, page, per_page,
 7                                    'api.get_followers', id=id)
 8     return jsonify(data)
 9 
10 @bp.route('/users/<int:id>/followed', methods=['GET'])
11 def get_followed(id):
12     user = User.query.get_or_404(id)
13     page = request.args.get('page', 1, type=int)
14     per_page = min(request.args.get('per_page', 10, type=int), 100)
15     data = User.to_collection_dict(user.followed, page, per_page,
16                                    'api.get_followed', id=id)
17     return jsonify(data)

因爲這兩條路由是特定於用戶的,所以它們具備id動態參數。 id用於從數據庫中獲取用戶,而後將user.followersuser.followed關係查詢提供給to_collection_dict(),因此但願如今你能夠看到,花費一點點額外的時間,並以通用的方式設計該方法,對於得到的回報而言是值得的。 to_collection_dict()的最後兩個參數是endpoint名稱和idid將在kwargs中做爲一個額外關鍵字參數,而後在生成連接時將它傳遞給url_for() 。

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

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

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

註冊新用戶

/users路由的POST請求將用於註冊新的用戶賬戶。 你能夠在下面看到這條路由的實現:

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

 1 from flask import url_for
 2 from app import db
 3 from app.api.errors import bad_request
 4 
 5 @bp.route('/users', methods=['POST'])
 6 def create_user():
 7     data = request.get_json() or {}
 8     if 'username' not in data or 'email' not in data or 'password' not in data:
 9         return bad_request('must include username, email and password fields')
10     if User.query.filter_by(username=data['username']).first():
11         return bad_request('please use a different username')
12     if User.query.filter_by(email=data['email']).first():
13         return bad_request('please use a different email address')
14     user = User()
15     user.from_dict(data, new_user=True)
16     db.session.add(user)
17     db.session.commit()
18     response = jsonify(user.to_dict())
19     response.status_code = 201
20     response.headers['Location'] = url_for('api.get_user', id=user.id)
21     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從命令行註冊一個新用戶:

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

編輯用戶

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

app/api/users.py:修改用戶。

 1 @bp.route('/users/<int:id>', methods=['PUT'])
 2 def update_user(id):
 3     user = User.query.get_or_404(id)
 4     data = request.get_json() or {}
 5     if 'username' in data and data['username'] != user.username and \
 6             User.query.filter_by(username=data['username']).first():
 7         return bad_request('please use a different username')
 8     if 'email' in data and data['email'] != user.email and \
 9             User.query.filter_by(email=data['email']).first():
10         return bad_request('please use a different email address')
11     user.from_dict(data, new_user=False)
12     db.session.commit()
13     return jsonify(user.to_dict())

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

一旦數據驗證經過,我可使用User模型的from_dict()方法導入客戶端提供的全部數據,而後將更改提交到數據庫。 該請求的響應會將更新後的用戶表示返回給用戶,並使用默認的200狀態代碼。

如下是一個示例請求,它用HTTPie編輯about_me字段:

1 (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。

 1 import base64
 2 from datetime import datetime, timedelta
 3 import os
 4 
 5 class User(UserMixin, PaginatedAPIMixin, db.Model):
 6     # ...
 7     token = db.Column(db.String(32), index=True, unique=True)
 8     token_expiration = db.Column(db.DateTime)
 9 
10     # ...
11 
12     def get_token(self, expires_in=3600):
13         now = datetime.utcnow()
14         if self.token and self.token_expiration > now + timedelta(seconds=60):
15             return self.token
16         self.token = base64.b64encode(os.urandom(24)).decode('utf-8')
17         self.token_expiration = now + timedelta(seconds=expires_in)
18         db.session.add(self)
19         return self.token
20 
21     def revoke_token(self):
22         self.token_expiration = datetime.utcnow() - timedelta(seconds=1)
23 
24     @staticmethod
25     def check_token(token):
26         user = User.query.filter_by(token=token).first()
27         if user is None or user.token_expiration < datetime.utcnow():
28             return None
29         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

因爲我對數據庫進行了更改,所以須要生成新的數據庫遷移,而後使用它升級數據庫:

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

帶Token的請求

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

爲了簡化使用token認證時客戶端和服務器之間的交互,我將使用名爲Flask-HTTPAuth的Flask插件。 Flask-HTTPAuth可使用pip安裝:

1 (venv) $ pip install flask-httpauth

Flask-HTTPAuth支持幾種不一樣的認證機制,都對API友好。 首先,我將使用HTTPBasic Authentication,該機制要求客戶端在標準的Authorization頭部中附帶用戶憑證。 要與Flask-HTTPAuth集成,應用須要提供兩個函數:一個用於檢查用戶提供的用戶名和密碼,另外一個用於在認證失敗的狀況下返回錯誤響應。這些函數經過裝飾器在Flask-HTTPAuth中註冊,而後在認證流程中根據須要由插件自動調用。 實現以下:

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

 1 from flask import g
 2 from flask_httpauth import HTTPBasicAuth
 3 from app.models import User
 4 from app.api.errors import error_response
 5 
 6 basic_auth = HTTPBasicAuth()
 7 
 8 @basic_auth.verify_password
 9 def verify_password(username, password):
10     user = User.query.filter_by(username=username).first()
11     if user is None:
12         return False
13     g.current_user = user
14     return user.check_password(password)
15 
16 @basic_auth.error_handler
17 def basic_auth_error():
18     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。

 1 from flask import jsonify, g
 2 from app import db
 3 from app.api import bp
 4 from app.api.auth import basic_auth
 5 
 6 @bp.route('/tokens', methods=['POST'])
 7 @basic_auth.login_required
 8 def get_token():
 9     token = g.current_user.get_token()
10     db.session.commit()
11     return jsonify({'token': token})

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

若是你嘗試直接向token API路由發送POST請求,則會發生如下狀況:

 1 (venv) $ http POST http://localhost:5000/api/tokens
 2 HTTP/1.0 401 UNAUTHORIZED
 3 Content-Length: 30
 4 Content-Type: application/json
 5 Date: Mon, 27 Nov 2017 20:01:00 GMT
 6 Server: Werkzeug/0.12.2 Python/3.6.3
 7 WWW-Authenticate: Basic realm="Authentication Required"
 8 
 9 {
10     "error": "Unauthorized"
11 }

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

 1 (venv) $ http --auth <username>:<password> POST http://localhost:5000/api/tokens
 2 HTTP/1.0 200 OK
 3 Content-Length: 50
 4 Content-Type: application/json
 5 Date: Mon, 27 Nov 2017 20:01:22 GMT
 6 Server: Werkzeug/0.12.2 Python/3.6.3
 7 
 8 {
 9     "token": "pC1Nu9wwyNt8VCj1trWilFdFI276AcbS"
10 }

如今狀態碼是200,這是成功請求的代碼,而且有效載荷包括用戶的token。 請注意,當你發送這個請求時,你須要用你本身的憑證來替換<username>:<password>。 用戶名和密碼須要以冒號做爲分隔符。

使用Token機制保護API路由

客戶端如今能夠請求一個token來和API endpoint一塊兒使用,因此剩下的就是向這些endpoint添加token驗證。 Flask-HTTPAuth也能夠爲我處理的這些事情。 我須要建立基於HTTPTokenAuth類的第二個身份驗證明例,並提供token驗證回調:

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

 1 # ...
 2 from flask_httpauth import HTTPTokenAuth
 3 
 4 # ...
 5 token_auth = HTTPTokenAuth()
 6 
 7 # ...
 8 
 9 @token_auth.verify_token
10 def verify_token(token):
11     g.current_user = User.check_token(token) if token else None
12     return g.current_user is not None
13 
14 @token_auth.error_handler
15 def token_auth_error():
16     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認證保護用戶路由。

 1 from app.api.auth import token_auth
 2 
 3 @bp.route('/users/<int:id>', methods=['GET'])
 4 @token_auth.login_required
 5 def get_user(id):
 6     # ...
 7 
 8 @bp.route('/users', methods=['GET'])
 9 @token_auth.login_required
10 def get_users():
11     # ...
12 
13 @bp.route('/users/<int:id>/followers', methods=['GET'])
14 @token_auth.login_required
15 def get_followers(id):
16     # ...
17 
18 @bp.route('/users/<int:id>/followed', methods=['GET'])
19 @token_auth.login_required
20 def get_followed(id):
21     # ...
22 
23 @bp.route('/users', methods=['POST'])
24 def create_user():
25     # ...
26 
27 @bp.route('/users/<int:id>', methods=['PUT'])
28 @token_auth.login_required
29 def update_user(id):
30     # ...

請注意,裝飾器被添加到除create_user()以外的全部API視圖函數中,顯而易見,這個函數不能使用token認證,由於用戶都不存在時,更不會有token了。

若是你直接對上面列出的受token保護的endpoint發起請求,則會獲得一個401錯誤。爲了成功訪問,你須要添加Authorization頭部,其值是請求/api/tokens得到的token的值。Flask-HTTPAuth指望的是」不記名」token,可是它沒有被HTTPie直接支持。就像針對基本認證,HTTPie提供了--auth選項來接受用戶名和密碼,可是token的頭部則須要顯式地提供了。下面是發送不記名token的格式:

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

撤銷Token

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

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

1 from app.api.auth import token_auth
2 
3 @bp.route('/tokens', methods=['DELETE'])
4 @token_auth.login_required
5 def revoke_token():
6     g.current_user.revoke_token()
7     db.session.commit()
8     return '', 204

客戶端能夠向/tokens URL發送DELETE請求,以使token失效。此路由的身份驗證是基於token的,事實上,在Authorization頭部中發送的token就是須要被撤銷的。撤銷使用了User類中的輔助方法,該方法從新設置token過時日期來實現撤銷操做。以後提交數據庫會話,以確保將更改寫入數據庫。這個請求的響應沒有正文,因此我能夠返回一個空字符串。Return語句中的第二個值設置狀態代碼爲204,該代碼用於成功請求卻沒有響應主體的響應。

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

1 (venv) $ http DELETE http://localhost:5000/api/tokens \
2     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:爲錯誤響應進行內容協商。

 1 from flask import render_template, request
 2 from app import db
 3 from app.errors import bp
 4 from app.api.errors import error_response as api_error_response
 5 
 6 def wants_json_response():
 7     return request.accept_mimetypes['application/json'] >= \
 8         request.accept_mimetypes['text/html']
 9 
10 @bp.app_errorhandler(404)
11 def not_found_error(error):
12     if wants_json_response():
13         return api_error_response(404)
14     return render_template('errors/404.html'), 404
15 
16 @bp.app_errorhandler(500)
17 def internal_error(error):
18     db.session.rollback()
19     if wants_json_response():
20         return api_error_response(500)
21     return render_template('errors/500.html'), 500

wants_json_response()輔助函數比較客戶端對JSON和HTML格式的偏好程度。 若是JSON比HTML高,那麼我會返回一個JSON響應。 不然,我會返回原始的基於模板的HTML響應。 對於JSON響應,我將使用從API blueprint中導入error_response輔助函數,但在這裏我要將其重命名爲api_error_response(),以便清楚它的做用和來歷。

http://localhost:5000/api/users/1
相關文章
相關標籤/搜索