资源和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
是集合中博客文章的总数,这种技术可应用于所有返回集合的路由