一. 创建API蓝本
REST API相关的路由是一个自成一体的程序子集, 所以为了更好的组织代码, 我们最好把这些路由放到独立的蓝本中。
1)API蓝本的结构
|-flasky
|-app/
|-api_1_0
|-__init__.py
|-users.py
|-posts.py
|-comments.py
|-authentication.py
|-errors.py
|-decorators.py
这个API蓝本中, 各资源分别在不同的模块中实现。 蓝本还包含处理认证, 错误以及提供自定义修饰器的模块。
2)蓝本的构造文件__init__.py
from flask import Blueprint
api = Blueprint('api', __name__)
from . import users, posts, comments, authentication, errors
3)注册API蓝本app/__init__.py
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')
#...
二. 错误处理
1)客户端能从web服务得到的常见状态码如下表所示:
HTTP状态码 | 名称 | 说明 |
200 | OK(成功) | 请求成功完成 |
201 | Created(创建) | 请求成功完成并创建了一个新资源 |
400 | Bad Request(坏请求) | 请求不可用或不一致 |
401 | Unauthorized(未授权) | 请求未包含认证信息 |
403 | Forbidden(禁止) | 请求中发送的认证密令无权访问目标 |
404 | Notfound(未找到) | URL对应的资源不存在 |
405 | Method not allowed(不允许使用的方法) | 指定资源不支持请求使用的方法 |
500 | Interval server error(内部服务器错误) | 处理请求的过程中发生意外错误 |
为所有客户端生成相应的一种方法是, 在错误处理程序中根据客户端请求的格式改写相应, 这种技术称为内容协商。
2)改进后的404错误处理程序
@main.app_errorhandler(404)
def page_not_found(e):
if request.accept_mimetypes.accept_json and \
not request.accept_mimetypes.accept_html: #如果客户端只接受json相应
response = jsonify({'error': 'not found'})
response.code_status = 404
return response
return render_template('404.html'), 404
app_error_handler作用在全局, 所有路由中发生404错误就会由该错误处理程序处理。
3)API蓝本中的错误处理程序app/api_1_0/errors.py
def forbidden(message):
response = jsonify({'error': 'forbidden', 'message': message})
response.status_code = 403
return response
def unauthorized(message):
response = jsonify({'error': 'unauthorized', 'message': message})
response.status_code = 401
return response
def bad_request(message):
response = jsonify({'error': 'bad request', 'message': message})
response.status_code = 400
return response
@api.errorhandler(ValidationError)#如果在api蓝本注册的路由中遇到ValidaitonError, 由改程序处理错误
def validation_error(e):
return bad_request(e.args[0])
这样视图函数就可以调用这些辅助函数生成错误响应了
4)ValidationError自定义错误app/exceptions.py
class ValidationError(ValueError):
pass
三. 使用Flask-HTTPAuth认证用户
我们的认证支持匿名用户, 邮箱密码认证, token令牌认证;
1)安装flask-httpauth
&pip install flask-httpauth
2)初始化Flask-HTTPAuth app/api_1_0/authentication.py
from flask_httpauth import HTTPBasicAuth
auth = HTTPBasicAuth()
@auth.verify_password
def 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.token_used = False
g.current_user = user
return user.verify_password(password)
@auth.error_handler
def auth_error():
return unauthorized('Invalid credentials')
#使用修饰器保护路由
@api.route('/posts/')
@auth.login_required
def get_posts():
pass
"""
上面的代码是否有些不好理解? 没关系, 我来介绍一下它的作用。
部署了上面的代码以后, 我们就可以使用修饰器@auth.login_reqired保护路由了,
只要客户端向该修饰器修饰的路由发出请求, 浏览器就会弹出一个对话框要求填写邮箱和密码, 即需要认证
如果填写的信息通过认证, 则可以访问路由, 未通过认证不可访问路由。
"""
#...email-password认证是默认的, token认证是我们自己添加的, 因为不想每次都发送敏感信息
#为了实现token认证我们还需要实现生成token, 验证token, 获取token的函数
#在那之前, 我们先处理一个问题, 在每个路由上都添加@auth.login_required修饰器稍显麻烦,
#我们可以在before_request函数上添加该修饰器, 那样该修饰器就会应用到所有api路由上了
@api.before_request
@auth.login_required
def before_request():
if not g.current_user.is_anonymous and not g.current_user.confirm:
return forbidden('Unconfirmed account')
#我们访问任意api蓝本注册的路由, 就会先访问@api.before_request修饰器注册的before_request函数
#因为before_request函数被@auth.login_required修饰器修饰, 所以我们就需要通过认证
#认证通过后才可以进入before_request函数, 执行完before_request函数后自动执行我们想访问的路由
#不过用户要是匿名用户或者是confirm属性为True的用户才可以
3)生成, 验证token app/models.py
class User(UserMixin, db.Model):
#...
def generate_auth_token(self, expiration):
s = Serializer(current_app.config['FLASKY_SECRET_KEY'], expires_in=expiration)
return s.dumps({'id': self.id})
@staticmethod
def verify_auth_token(token): #验证失败返回None, 验证成功返回对应用户
s = Serializer(current_app.config['FLASKY_SECRET_KEY'])
try:
data = s.loads(token)
except:
return None
return User.query.get(data['id'])
4)发送token app/api_1_0/authentications.py
@api.route('/token')
def get_token(): #能通过认证才能访问该路由, 有三种方式通过认证: 匿名用户, token认证, email-password认证
if g.current_user.is_anonymous or g.token_used == True: #匿名用户无法获取token, 无法用旧token获取新token
return unauthorized('Invalid credentials')
return jsonify({'token': g.current_user.generate_auth_token(expiration=3600), 'expiration': 3600})
四. 资源和json的转换
1)post&json_post 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,
'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
@staticmethod
def from_json(json_post):
body = json_post.get('body')
if body == None or body == '':
raise ValidationError('post does not have a body')
return Post(body=body)
"""
我们创建Post实例的时候只需要使用body字段,post的author属性在视图函数中定义。
"""
#http客户端和web服务之间传递信息使用json编码, 所以web服务要实现json和资源之间的相互转换
#把post请求发来的json转换成资源存入数据库
#把get请求请求的资源转换成json发送给客户端
2)user&json_user app/models.py
class User(UserMixin, db.Model):
def to_json(self):
json_user = {
'url': url_for('api.get_user', 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, password, role等属性放入json字典
#这说明我们把资源转成json时没必要把所有属性都放进去
#user并没有post_count属性, 这说明我们可以在json里面增加虚拟属性
3)comment&json_comment app/models.py
class Comment(db.Model):
#...
def to_json(self):
json_comment = {
'url': url_for('api.get_comment', id=self.id, _external=True),
'body': self.body,
'body_html': self.body_html,
'timestamp': self.timestamp,
'author_url': url_for('api.get_user', id=self.author.id, _external=True),
'post_url': url_for('api.get_post', id=self.post.id, _external=True)
}
return json_comment
@staticmethod
def from_json(json_comment):
body = json_comment.get('body')
if body == None or body == '':
raise ValidationError('comment does not have a body')
return Comment(body)
五. 实现资源端点
1)app/api_1_0/posts.py
from ..models import Post, Permission
from flask import request, g, jsonify, url_for, current_app
from . import api
from .. import db
from .errors import forbidden
from .decorators import permission_required
@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
})
@api.route('/posts/<int:id>')
def get_post(id):
post = Post.query.get_or_404(id)
return jsonify(post.to_json())
@api.route('/posts/', methods=['POST'])
@permission_required(Permission.WRITE_ARTICLES) #创建文章的用户需要写权限, 该修饰函数定义在文章后面
def new_post():
post = Post.from_json(request.json) #利用请求发送的json数据创建post实例, 如果遇到ValidationError, 由errorhandler处理,视图函数变得简洁
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)}
@api.route('/posts/<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())
2)app/api_1_0/users.py
from . import api
from ..models import User
from flask import jsonify, request, current_app, url_for
@api.route('/users/<int:id>')
def get_user(id):
user = User.query.get_or_404(id)
return jsonify(user.to_json())
@api.route('/users/<int:id>/posts/')
def get_user_posts(id):
user = User.query.get_or_404(id)
page = request.args.get('page', 1, type=int)
pagination = user.posts.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_user.posts', id=id, page=page-1)
next = None
if pagination.has_next:
next = url_for('api.get_user_posts', id=id, page=page+1)
return jsonify({
'posts': [post.to_json() for post in posts],
'prev': prev,
'next': next,
'count': user.posts.count()
})
@api.route('/users/<int:id>/timeline/')
def get_user_followed_posts(id):
user = User.query.get_or_404(id)
page = request.args.get('page', 1, type=int)
pagination = user.followed_posts.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_user_followed_posts', id=id, page=page-1)
next = None
if pagination.has_next:
next = url_for('api.get_user_followed_posts', id=id, page=page+1)
return jsonify({
'posts': [post.to_json() for post in posts],
'prev': prev,
'next': next,
'count': user.followed_posts.count()
})
3)app/api_1_0/comments.py
from . import api
from flask import request, current_app, jsonify, url_for, g
from ..models import Comment, Permission, Post
from .decorators import permission_required
from .. import db
@api.route('/comments/')
def get_comments():
page = request.args.get('page', 1, type=int)
pagination = Comment.query.order_by(Comment.timestamp.desc()).paginate(page, per_page=current_app.config['FLASKY_COMMENTS_PER_PAGE'], error_out=False)
comments = pagination.items
prev = None
if pagination.has_prev:
prev = url_for('api.get_comments', page=page-1)
next = None
if pagination.has_next:
next = url_for('api.get_comments', page=page+1)
return jsonify({
'comments': [comment.to_json() for comment in comments],
'prev': prev,
'next': next,
'count': pagination.total()
})
@api.route('/comments/<int:id>')
def get_comment(id):
comment = Comment.query.get_or_404(id)
return jsonify(comment.to_json())
@api.route('/posts/<int:id>/comments/')
def get_post_comments(id):
post = Post.query.get_or_404(id)
page = request.args.get('page', 1, type=int)
pagination = post.comments.order_by(Comment.timstamp.desc()).paginate(page, per_page=current_app.config['FLASKY_COMMENTS_PER_PAGE'], error_out=False)
comments = pagination.items
prev = None
if pagination.has_prev:
prev = url_for('api.get_post.comments', id=id, page=page-1)
next = None
if pagination.has_next:
next = url_for('api.get_post_comments', id=id, page=page+1)
return jsonify({
'comments': [comment.to_josn() for comment in comments],
'prev': prev,
'next': next,
'count': pagination.total
})
@api.route('/posts/<int:id>/comments/', methods=['POST'])
@permission_required(Permission.COMMIT)
def new_post_comment(id):
post = Post.query.get_or_404(id)
comment = Comment.from_json(request.json)
comment.author = g.current_user
comment.post = post
db.session.add(comment)
db.session.commit()
return jsonify(comment.to_json), 201, \
{'location': url_for('api.get_comment', id=comment.id, _external=True)}
六. permission_required修饰器 app/api_1_0/decorations.py
还记得我们是如何利用该修饰器修饰路由, 以达到限制权限用户才能访问路由的目的的吗?
@api.route('/posts/', methods=['POST'])
@permission_required(Permission.WRITE_ARTICLES) #只有具有写权限的用户才能访问该路由
def new_post():
pass
我们来推理一下permission_required函数的构造, 首先修饰器语句等价于:
new_post = permission_required(Permission.WRITE_ARTICLES)(new_post)
即:
new_post() = permission_required(Permission.WRITE_ARTICLES)(new_post)()
当用户访问该路由时首先需要通过认证, 通过认证以后g.current_user中存储的就是通过认证的用户, 然后如果用户是匿名用户或者confirm属性为True, 通过before_request函数, 即可访问路由函数new_post()
就相当于访问:
permission_required(Permission.WRITE_ARTICLES)(new_post)()
这样的话看来是两层闭包, 通过两层调用把Permission.WRITE_ARTICLES和new_post参数传到最里面的函数, 最里面的函数进行用户权限认证, 通过的话执行new_post函数即可:
def permission_required(permission):
def decorator(f):
@wraps(f)
def decorator_function(): if not g.current_user.can(permission): return forbidden('Insufficient permissions') return f() return decorator_function return decorator
如果被修饰的函数有参数的话, 版本就是下面这样:(有无参数都适用)
def permission_required(permission):
def decorator(f):
@wraps(f)
def decorator_function(*nkwargs, **kwargs):
if not g.current_user.can(permission):
return forbidden('Insufficient permissions')
return f(*nkwargs, **kwargs)
return decorator_function
return decorator
@wraps修饰器的作用是, 保持被修饰函数的__doc__和__name__属性。
七. 使用HTTPie测试Web服务
测试Web服务必须使用HTTP客户端, 最常使用的在命令行中测试Web服务的客户端是curl, httpie, 后者命令更简洁, 可读性也更高, 我们使用后者。
1)安装httpie
&pip install httpie
2)运行web服务
3)测试web服务
GET请求可按如下方式发起:
...
因为这是第一页, 所以prev参数为None, 但是返回了获取下一页的url和总页数。
匿名用户可发送空邮件地址和密码来发起相同的请求:
下面这个命令发送POST请求以添加一篇新博客文章:
如此推测, request.json就是字典:
{'body': "I'm adding a post2 from the *command line*."}
而且视图函数返回语句:
return jsonify(post.to_json()), 201, \
{'location': url_for('api.get_post', id=post.id, _external=True)}
201是status_code,
{'location': url_for('api.get_post', id=post.id, _external=True)}是location首部。
要想使用认证令牌, 可向/api/v1.0/token 发送请求:
在接下来的1小时内, 这个令牌可用于访问API, 请求时要和空密码一起发送:
至此, Flasky的功能开发阶段就完全结束啦~~~
很显然, 下一步我们要部署Flasky。 在部署过程中, 我们会遇到新的挑战~