資源和JSON的序列化轉換
開發Web程序時,經常需要在資源的內部表示和JSON之間進行轉換,JSON是HTTP請求和響應使用的傳輸格式,下例是新添加到Post類中的to_json()
方法
# app/models.py
class Post(db.Model):
#...
def to_json(self):
json_post = {
"url": url_for('api.get_post', id=self.id, _external=True),
"body": self.body,
"doby_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
url、author和comments字段分別返回各自資源的URL,因此它們使用url_for()
生成,所調用的路由即將在API藍本中定義,注意,所有的url_for()
方法都指定了參數_external=True
,這麼做是爲了生成完整的URL,而不是生成傳統Web程序中經常使用的相對URL
這段代碼還說明表示資源時可以使用虛構的屬性,comment_count
字段是博客文章的評論數量,並不是模型的真實屬性,它之所以包含在這個資源中是爲了便於客戶端使用
User模型的to_json()
方法可以按照Post模型的方式定義,如下
# app/models.py
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
爲了保護隱私,這個方法中用戶的某些屬性沒有加入響應,例如email和role,這段代碼再次聲明,提供給客戶端的資源表示沒必要和數據庫模型的內部表示完全一致
把JSON轉換成模型時面臨的問題是,客戶端提供的數據可能無效、錯誤或者多餘,下例是從JSON格式數據創建Post模型實例的方法
# app/models.py
from app.exceptions import ValidationError
class 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_html
屬性忽略了,因爲只要body屬性的值發生變化,就會觸發一個SQLAlchemy事件,自動在服務器端渲染Markdown,除非允許客戶端倒填日期(這個程序並不提供此功能),否則無需指定timestamp
屬性,由於客戶端無權選擇博客文章的作者,所以沒有使用author
字段,author
字段唯一能使用的值是通過認證的用戶,comments
和comment_count
屬性使用數據庫關係自動生成,因此其中沒有創建模型所需的有用信息,最後,url
字段也被忽略了,因爲在這個實現中資源的URL由服務器指派,而不是客戶端
如何檢查錯誤,如果沒有body
字段或者其值爲空,from_json()
方法會拋出ValidationError
異常,在這種情況下,拋出異常纔是處理錯誤的正確處理方法,因爲from_json()
方法並沒有掌握處理問題的足夠信息,唯有把錯誤交給調用者,由上層代碼處理這個錯誤,ValidationError
類是Python中的ValueError
類的簡單子類,具體定義如下:
# app/exceptions.py
class ValidationError(ValueError):
pass
現在程序需要向客戶端提供適當的響應以處理這個異常,爲了避免在視圖函數中編寫捕獲異常的代碼,我們可創建一個全局異常處理程序,對於ValidationError
異常,其處理程序如下:
# app/api_1_0/errors.py
@api.errorhandler(ValidationError)
def validation_error(e):
return bad_request(e.args[0])
這裏使用的errorhandler
裝飾器和註冊HTTP狀態碼處理程序時使用的是同一個,只不過此時接受的參數是Exception
類,只要拋出了指定類的異常,就會調用被裝飾的函數,注意這個裝飾器從API藍本中調用,所以只有當處理藍本中的路由時拋出了異常纔會調用這個處理程序
使用這個技術時,視圖函數中的代碼可以寫的十分簡潔明瞭,而且無需檢查錯誤
@api.route('/posts/', methods=['POST'])
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())
實現資源端點
現在我們需要實現用於處理不同資源的路由,GET請求往往是最簡單的,因爲它們只返回信息,無需修改信息,下例是博客文章的兩個GET請求處理程序
# app/api_1_0/posts.py
@api.route('/posts/')
@auth.login_required
def get_posts():
posts = Post.query.all()
return jsonify({ 'posts': [post.to_json() for post in posts] })
@api.route('/posts/<int:id>')
@auth.login_required
def get_post(id):
post = Post.query.get_or_404(id)
return jsonify(post.to_json())
第一個路由處理獲取文章集合的請求,這個函數使用列表推導生成所有文章的JSON版本,第二個路由返回單篇博客文章,如果在數據庫中沒找到指定id對應的文章,則返回404錯誤
404錯誤的處理程序在程序層定義,如果客戶端請求JSON格式,就要返回JSON格式響應,如果要根據Web服務定製響應內容,也可在API藍本中重新定義404錯誤處理程序
博客文章資源的POST請求處理程序把一篇新博客文章插入數據庫,路由的定義如下例:
# app/api_1_0/posts.py
@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
裝飾器中,確保通過認證的用戶有寫博客文章的權限,得益於前面實現的錯誤處理程序,創建博客文章的過程變得很直觀,博客文章從JSON數據中創建,其作者是通過認證的用戶,這個模型寫入數據庫之後,會返回201狀態碼,並把Location
首部的值設爲剛創建的這個資源的URL
爲便於客戶端操作,響應的主體中包含了新建的資源,如此一來,客戶端就無需再創建資源後再立即發起一個GET請求以獲取資源
用來防止未授權用戶創建新博客文章的permission_required
裝飾器和程序中使用的類似,但會針對API藍本進行自定義,具體實現如下:
# app/api_1_0/decotators.py
def permission_required(permission):
def decorator(f):
@wraps(f)
def decoreted_function(*args, **kwargs):
if not g.current_user.can(permission):
return forbidden('Insufficient permissions')
return f(*args, **kwargs)
return decorator
博客文章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_app.can(Psermission.ADMINISTER):
return forbidden("Insufficient permissions")
post.body = request.json.get('body', post.body)
db.session.add(post)
return jsonify(post.to_json())
本例中要進行的權限檢查更爲複雜,裝飾器用來檢查用戶是否有寫博客文章的權限,但爲了確保用戶能編輯博客文章,這個函數還要保證用戶是文章的作者或是管理員,這個檢查直接添加到視圖函數中,如果這種函數要應用於多個視圖函數,爲避免代碼重複,最好的方法是爲其創建裝飾器
因爲程序不允許刪除文章,所以沒必要實現DELETE請求方法的處理程序
用戶資源和評論資源的處理程序實現方法類似,下表列出了這個程序要實現的部分資源:
資源URL | 方法 | 說明 |
---|---|---|
/users/<int:id> |
GET | 一個用戶 |
/users/<int:id>/posts/ |
GET | 一個用戶發佈的博客文章 |
/user/<int:id>/timeline/ |
GET | 一個用戶所關注用戶發佈的文章 |
/posts/ |
GET、POST | 所有博客文章 |
/posts/<int:id> |
GET、PUT | 一篇博客文章 |
/posts/<int:id/>comments/ |
GET、POST | 一篇博客文章中的評論 |
/comments/ |
GET | 所有評論 |
/comments/<int:id> |
GET | 一篇評論 |
這些資源只允許客戶端實現Web程序提供的部分功能,支持的資源可以按需擴展,比如說提供關注者資源,支持評論管理,以及實現客戶端需要的其他功能
分頁大型資源集合
對大型資源集合來說,獲取集合的GET請求消耗很大,而且難以管理,和Web程序一樣,Web服務也可以對集合進行分頁,實現方式見下:
# 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
})
JSON響應格式中的posts
字段依舊包含各篇文章,但現在這只是完整集合的一部分,如果資源有上一頁和下一頁,prev
和next
字段分別表示上一頁和下一頁資源的URL,count
是集合中博客文章的總數,這種技術可應用於所有返回集合的路由