chapter 14 - 應用編程介面(API)
其實網站建設在功能上,截至前面一章就已經都實現了。這章及後面的幾章,都是高級內容,對於像我這樣的初學者來說,個人覺得其實並沒那麼重要了。所以本章我在這裡的筆記也會比較簡略。
本章講的是Web API,具體說就是為我們寫好的網站內容開發出可供第三方使用的API介面。書中提到了一個概念:REST(Representational State Transfer)——表現層狀態轉移。這是一種Web服務架構。它具有6個特徵:
- 客戶端-伺服器
- 無狀態
- 緩存
- 介面統一
- 系統分層
- 按需代碼
這個概念其實個人覺得不好理解。目前我的理解是,因為目前的網站架構都提倡MVC結構,把前端、後台給明確區分開來,做前端的可以完全不管後台是如 何實現的,它只需要後台所提供的數據,然後用於展示給網民。而這個前端,估計就與這裡說到的「表現層」是差不多同個東西,於是這個表現層狀態轉移,也就是 把後台的東西「轉移」給前端。
可能我的理解會有錯,各位看到的同行盡可以指出。Whatever,知道它是個什麼東西有什麼作用就好。想了解更多信息,可以到網上找找。
本書中從後台「轉移」到前端的數據是JSON數據
14.2 使用Flask提供REST Web服務
14.2.1 創建API藍本
為了更好地組織代碼,API文件最好放到獨立的藍本中。目錄結構如下:
|-flasky
|-app |-api_1_0 |-__init__.py |-users.py |-posts.py |-comments.py |-authentication.py |-errors.py|-decorators.py
app/api_1_0/___init___.py API藍本的構造文件
from flask import Blueprintapi = Blueprint(api, __name__)from .import authentication, posts, users, comments, errors
app/___init___.py 註冊API藍本
def create_app(config_name): # 原有代碼 from .api_1_0 import api as api_1_0_blueprint app.register_blueprint(api_1_0_blueprint, url_prefix=/api/v1.0) # 原有代碼
14.2.2 錯誤處理
這裡的錯誤處理其實和普通的HTTP錯誤處理沒太大差別,主要是返回的錯誤提示內容需要自定義。
app/main/errors.py 使用HTTP內容協商處理錯誤
@main.app_errorhandler(404)def page_not_found(e): if request.accept_mimetypes.accept_json and not request.accept_mimetypes.accept_html: response = jsonify({error: not found}) response.status_code = 404 return response return render_template(404.html), 404
以上是404錯誤的處理函數。這裡函數中的if語句用於檢查請求首部(這個請求有可能是瀏覽器,也有可能是其它客戶端),判斷客戶端是否接受json和html,如果只接收json,那就返回json數據,否則返回HTML文檔。500錯誤處理程序,可參考本書的Github代碼。
403錯誤的處理程序如下:
app/main/errors.py
def forbidden(message): response = jsonify({error: forbiden, message: message}) response.status_code = 403 return response
作者之所以要把404、500這兩個錯誤的處理方法和403等其它HTTP錯誤分開說明,是因為通常404和500錯誤是由Flask自己生成的,並且這兩個錯誤通常會返回HTML響應,而其它的則只需要返回個狀態碼和提示信息就行了。
14.2.3 使用Flask-HTTPAuth認證用戶
跟許多公開的API一樣,使用Flask程序的API,也應該要先認證才能使用。由於REST Web服務的特徵之一就是無狀態,即伺服器在兩次請求之間不能記住客戶端的任何信息。於是客戶端每次發送請求時,都要包含認證信息。在這裡使用HTTP認 證是一很好的選擇,HTTP認證中,認證信息包含在請求的Authorization首部中。
Flask-HTTPAuth擴展提供了一個便利的HTTP認證方式。可使用pip安裝:
(venv) $ pip install flask-httpauth
安裝完後,需要在程序中初始化Flask-HTTPAuth
app/api_1_0/authentication.py
from flask_httpauth import HTTPBasicAuthauth = HTTPBasicAuth()@auth.verify_passworddef verify_password(email, password): if email == : g.current_user = AnonymousUser() return True user = User.query.filter_by(email = email).first() if not user: return False g.current_user = user return user.verify_password(password)
由以上代碼可以看到,這個API其實是允許匿名使用的。在後面就可以看到,匿名用戶與認證用戶在API的使用許可權上是不一樣的,也就是說兩者能夠從API獲得的功能是不同的。
針對認證失敗的情況,也需要做出處理:
app/api_1_0/authentication.py
@auth.error_handlerdef auth_error(): return unauthorized(Invalid credentials)
書中在這裡,還有一段說明:
為了保護路由,可以使用修飾器 auth.login_required:
@api.route(/posts/)
@auth.login_required
def get_posts():
pass
這段代碼是為了再下來的這一段說而展示的:
不過,這個藍本中的所有路由都要使用相同的方式進行保護,所以我們可以在beforerequest處理程序中使用一次loginrequired修飾器,應用到整個藍本
所以千萬不要把說明中的那段代碼也寫到authentication.py文件中去,否則會出現路由錯誤:
這個錯誤曾困擾了我兩天,一直沒法找到原因。
接下來就是在before_request處理程序中進行認證的代碼:
app/api_1_0/authentication.py
from .errors import forbidden@api.before_request@auth.login_requireddef before_request(): if not g.current_user.is_anonymous and not g.current_user.confirmed: return forbidden(Unconfirmed account)
應當注意的是,書中作者在些段代碼開頭處是from .errors import forbidden_error,這明顯是錯的,因為我們前面在修改error.py文件時,並沒有定義forbidden_error函數或類,並且在上面的函數最後返回處使用的是forbidden而不是forbidden_error,所在開頭處引入的應該是forbidden。
從以上程序的if語句中可看出,這個函數還可以拒絕已通過認證但沒有確認帳戶的用戶。
14.2.4 基於令牌的認證
基於令牌的認證其實在第8章用戶認證裡面已經講過了,這裡的需求和原理其實是一樣的。具體可看回第8章。 為了生成和驗證令牌,需要在User模型中定義兩個新方法:
app/models.py
class User(db.Model): # 原有代碼 def generate_auth_token(self, expiration): s = Serializer(current_app.config[SECRET_KEY], expires_in=expiration) return s.dumps({id: self.id}) @staticmethod def verify_auth_token(token): s = Serializer(current_app.config[SECRET_KEY]) try: data = s.loads(token) expect: return None return User.query.get(data[id])
以上代碼和第8章的令牌認證相差不大,此處不再解釋。
由於這裡使用了令牌,所以前面寫的驗證密碼的verify_password函數要做出相應的修改,讓它不僅可以驗證密碼,還可以驗證令牌:
app/api_1_0/authentication.py
@auth.verify_passworddef verify_password(email_or_token, password): if email_or_token == : g.current_user = AnonymousUser() return True if password == : g.current_user = User.verify_auth_token(email_or_token) g.token_used = True return g.current_user is not None user = User.query.filter_by(email=email_or_token).first() if not user: return False g.current_user = user g.token_used = False return user.verify_password(password)
這裡增加了一個g.token_used變數,用於區分密碼認證和令牌認證。
伺服器生成了認證令牌,需要將其發送給客戶端,客戶端後續可以用這個令牌來向伺服器申請認證:
app/api_1_0/authentication.py 生成認證令牌
@api.route(/token)def get_token(): if g.current_user.is_anonymous or g.token_used: return unauthorized(Invalid credentials) return jsonify({token: g.current_user.generate_auth_token(expiration=3600), expiration: 3600})
應當注意的是,在if條件中,應該使用g.current_user.is_anonymous ,不加括弧的,而不是書中的g.current_user.is_anonymous() 為了避免客戶端使用舊令牌申請新令牌,需要先檢查g.token_used變數的值,如果已經使用了令牌進行認證就拒絕請求。
14.2.5 資源和JSON的序列化轉換
所謂資源和JSON的轉換,也就是把所請求的信息(在這裡就是文章信息和評論等)轉換成JSON格式。
app/models.py 把文章轉換成JSON格式
class Post(db.Model): # 原有代碼 def to_json(self): json_post = { url: url_for(api.get_post, id=self.id, _external=True), body: self.body, body_html: self.body_html, timestamp: self.timestamp, author: url_for(api.get_user, id=self.author_id, _external=True), comments: url_for(api.get_post_comments, id=self.id, _external=True) comment_count: self.comments.count() } return json_post
如果有了解過JSON的朋友,理解以上函數應該不是問題,無非就是把已有的欄位返回到一個JSON數據中,比較特殊的是url、comments、author,這三個欄位返回的是各自對應的URL,在url_for方法中指定了參數_external=True是為了生成完整的URL,也就是絕對地址。
類似地,可以把用戶信息轉換成JSON格式:
app/models.py 把用戶轉換成JSON格式
class User(UserMixin, db.Model): # 原有代碼 def to_json(self): json_user = { url: url_for(api.get_post, id=self.id, _external=True), username: self.username, member_since: self.member_since, last_seen: self.last_seen, posts: url_for(api.get_user_posts, id=self.id, _external=True), followed_posts: url_for(api.get_user_followed_posts, id=self.id, _external=True), post_count: self.posts.count() } return json_user
書中還強調了兩次:提供給客戶端的資源沒必要和資料庫模型的內部表示完全一致。其實道理也很簡單,我們向第三方開放API,我們想提供什麼數據讓他們用,自然是由我們自己來決定的了。例如客戶端請求文章信息,我們只返迴文章正文給他也是可以的。
然後是允許從請求客戶端用JSON格式創建一篇博客文章
app/models.py 從JSON格式數據創建一篇博客文章
from app.exceptions import ValidationErrorclass Post(db.Model): # 原有代碼 @staticmethod def from_json(json_post): body = json_post.get(body) if body is None or body == : raise validationError(post does not have a body) return Post(body=body)
由上函數可見,用JSON數據創建文章,唯一可自定義的欄位是文章內容,文章作者自動選擇為通過認證的用戶,時間也自動生成。如果body是空,則拋出異常,這裡的異常直接返回給調用者,由上層代碼處理:
app/exceptions.py ValidationError異常
class ValidationError(ValueError): pass
注意,以上只是定義了異常,還沒有進行調用和定義該怎麼處理異常。處理異常的代碼放在了一個全局的異常處理程序中:
app/api_1_0/errors.py API中ValidationError異常的處理程序
@api.errorhandler(ValidationError)def validation_error(e): return bad_request(e.args[0])
這裡的代碼我並沒有去細看它的實現,反正從代碼表面看來,就是把異常交給另一個函數去處理了,我們並不需要去管它是怎麼處理的。有興趣的朋友可以根據書中的說明去研究。
14.2.6 實現資源端點
實現資源端點,其實就是實現路由函數,讓API的使用者知道要把請求發送到哪裡。
app/api_1_0/posts.py 文章資源GET請求的處理程序
@api.route(/posts/)@auth.login_requireddef get_posts(): posts = Post.query.all() return jsonify({posts: [post.to_json() for post in posts]})@api.route(/posts/<int:id>)@auth.login_requireddef get_post(id): post = Post.query.get_or_404(id) return jsonify(post.to_json())
第一個路由處理獲取文章集合的請求,第二個路由返回單篇文章,如果沒有找到指定id對應的文章,則返回404錯誤。
用JOSN數據發表文章的路由如下:
app/api_1_0/posts.py 文章資源POST請求的處理程序
@api.route(/posts/, methods=[POST])@permission_required(Permission.WRITE_ARTICLES)def new_post(): post = Post.from_json(request.json) post.author = g.current_user db.session.add(post) db.session.commit() return jsonify(post.to_json()), 201, {Location: url_for(api.get_post, id=post.id, _external=True)}
這裡使用了permission_required修飾器,確保通過認證的用戶才有寫文章的許可權。return 語句中,程序返回了所發表的文章,以及201狀態碼,同時把首部Location欄位的值設置為所發表的文章的URL。所使用的修飾器的具體實現如下:
app/api_1_0/decorators.py permission_required修飾器
def permission_required(permission): def decorator(f): @wraps(f) def decorated_function(*args, **kwargs): if not g.current_user.can(permission); return forbidden(Insufficient permissions) return f(*args, **kwargs) return decorated_function return decoration
博客文章PUT請求處理程序用於更新現有資源:
app/api_1_0/posts.py
@api.route(/posts/<int:id>, methods=[PUT])@permission_required(Permission.WRITE_ARTICLES)def edit_post(id): post = Post.query.get_or_404(id) if g.current_user != post.author and not g.current_user.can(Permission.ADMINISTER): return forbidden(Insufficient permissions) post.body = request.json.get(body, post.body) db.session.add(post) return jsonify(post.to_json())
這裡主要是先進行許可權的檢查,然後再決定是否允許修改。
除了文章外,還需要實現用戶信息和評論信息的處理,具體的代碼需要查看本書的Github代碼
14.2.7 分頁大型資源集合
這節講的是分頁處理,讓客戶端可以通過API來獲取多頁內容。
app/api_1_0/posts.py 分頁文章資源
@api.route(/posts/)def get_posts(): page = request.args.get(page, 1, type=int) pagination = Post.query.paginate( page, per_page = current_app.config[FLASKY_POSTS_PER_PAGE], error_out=False) posts = pagination.items prev = None if pagination.has_prev: prev = url_for(api.get_posts, page=page-1, _external=True) next = None if pagination.has_next: next = url_for(api.get_posts, page=page+1, _external=True) return jsonify({ posts: [post.to_json() for post in posts], prev: prev, next: next, count: pagination.total })
注意上面函數中的路由/posts/,這類似於一個文件夾的形式,所以其實這個路由返回的是一個資源集合,具體說就是以上路由函數會返回由多篇文章組成的資源集合。
14.2.8 使用HTTPie測試Web服務
測試Web服務需要使用HTTP客戶端。常用的在命令行中使用(對,也有GUI軟體可用於HTTP測試的,還有基於瀏覽器的,可以網上搜一下)的是crul和HTTPie。後者的命令行更簡潔。可使用pip安裝:
(venv) $ pip install httpie
安裝完成後,在進行測試前,記得要先啟動網站伺服器 (venv) $ python manage.py runserver,不然測試的HTTP請求誰來處理。
發送GET請求:(venv) $ http --json --auth <eamil>:<password> GET http://127.0.0.1:5000/api/v1.0/posts/
伺服器將返回第一頁的內容,並且指出第二頁的URL。注意GET鏈接,末尾處是有斜線的,因為前面定義的 posts路由中就是帶有斜線的,如果像書中的那樣不帶斜線,那麼伺服器是會返回301跳轉(指導跳轉到帶斜線的地址),如果是瀏覽器訪問,得到301跳 轉時,瀏覽器會自動處理這個301跳轉(跳轉到伺服器返回的地址)。但由於現在是API訪問,這個跳轉並沒有被處理,所以如果訪問末尾不帶斜線的地址,將 會得到這樣的結果:
HTTP/1.0 301 MOVED PERMANENTLYContent-Length: 281Content-Type: text/html; charset=utf-8Date: Wed, 16 Nov 2016 11:33:47 GMTLocation: http://127.0.0.1:5000/api/v1.0/posts/Server: Werkzeug/0.11.11 Python/3.5.1<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 3.2 Final//EN"><title>Redirecting...</title><h1>Redirecting...</h1><p>You should be redirected automatically to target URL: <a href="http://127.0.0.1:5000/api/v1.0/posts/">http://127.0.0.1:5000/api/v1.0/posts/</a>. If not click the link.
匿名訪問:
(venv) $ http --json --auth : GET http://127.0.0.1:5000/api/v1.0/posts/
其實就是把用戶名和密碼都留空,但是那個冒號還是要的。
添加新文章:
(venv) $ http --auth <email>:<password> --json POST > http://127.0.0.1:5000/api/v1.0/posts/ > "body=Im adding a post from the *command line*."
body中的兩個星號間的內容,會被系統自動識別進行Markdown渲染成斜體。
使用認證令牌:(venv) $ http --auth <email>:<password> --json GET http://127.0.0.1:5000/api/v1.0/token
在我進行測試時,使用上述命令獲取令牌,得到的是出錯信息:
...File "/home/cavin/Code/Python/flask/app/api_1_0/authentication.py", line 43, in get_token return jsonify({token: g.current_user.generate_auth_token(expiration=3600), expiration: 3600})...TypeError: beyJpYXQiOjE0NzkyOTU1NTcsImV4cCI6MTQ3OTI5OTE1NywiYWxnIjoiSFMyNTYifQ.eyJpZCI6MX0.6w2NeDDAnVe_Qmd2wi7PncJRR-AJl6ssdA46JGiAPdQ is not JSON serializable
在錯誤提示信息里,唯一與我們寫的代碼有關的,就是上面錯誤提示碼中的開始兩行。從末尾的錯誤提示那裡可以看到,令牌是已經生成了的,但是它是一個bytes對象。我們可以把它解碼成字元串:
app/api_1_0/authentication.py
...return jsonify({token: g.current_user.generate_auth_token(expiration=3600).decode(), expiration: 3600})
再進行測試時,就可以成功地獲取返回了信息了。然後就可以使用返回的令牌來訪問API:
(venv) $ http --json --auth <長長的令牌串>: GET http://127.0.0.1:5000/api/v1.0/posts/
注意,使用令牌訪問時,密碼是空的。而且,令牌的有效期(已經被我們設定)是1小時,過期之後,請求會返回401錯誤,表示需要重新獲取令牌。
本章到些結束,本章用到的各個文件程序,都是需要先導入各種包的,但是書中並沒有講到,需要讀者自己去查看本書的Github倉庫進行對比添加了。
推薦閱讀:
※Python中是否需要避免使用相對引用?
※Django 有哪些局限性?
※requests 和 scrapy 在不同的爬蟲應用中,各自有什麼優勢?
※如何快速學會一個web框架?
※Tornado 非同步讀寫文件的方法?