使用Flask创建REST
Web服务很简单,使用熟悉的route()
装饰器及其methods
可选参数可以声明服务所提供资源URL的路由,处理JSON数据同样简单,因为请求中包含的JSON数据可通过request.json
这个Python字典获取,并且需要包含JSON的响应可以使用Flask提供的辅助函数jsonify()
从Python字典中生成
创建API蓝本
REST API 相关的路由是一个自成一体的程序子集,所以为了更好的组织代码,我们最好把这些路由放到独立的蓝本中,这个程序API蓝本的基本结构如下:
|-flasky|-app/|-api_1_0|-__init__.py|-users.py|-posts.py|-comments.py|-authentication.py|-errors.py|-decorators.py 12345678910
注意,API包的名字中有个版本号,如果需要创建一个向前兼容的API版本,可以添加一个版本号不同的包,让程序同时支持两个版本的API
在这个API蓝本中,各资源分别在不同的模块中实现,蓝本中还包含处理认证、错误以及提供自定义装饰器的模块,蓝本的构造文件如下所示:
# app/api_1_0/__init__.pyfrom flask import Blueprint
api = Blueprint('api', __name__)from . import authentication, posts, users, comments, errors#...12345678
注册API蓝本的代码如下:
# app/__init__.pydef 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')return app12345678
错误处理
REST Web服务将请求的状态告知客户端时,会在响应中发送适当的HTTP状态码,并将额外信息放入响应主体,客户端能从Web服务得到的常见状态码如下表
HTTP状态码 | 名称 | 说明 |
---|
200 | OK(成功) | 请求成功完成 |
201 | Created(已创建) | 请求成功完成并创建了一个新资源 |
400 | Bad request(坏请求) | 请求不可用或不一致 |
401 | Unauthorized(未授权) | 请求中为包含认证信息 |
403 | Forbidden(禁止) | 请求中发送的认证密令无权访问目标 |
404 | Notfound(未找到) | URL对应资源不存在 |
405 | Methods not allowed(不允许使用的方法) | 指定资源不支持请求使用方法 |
500 | Internal server error(内部服务器错误) | 处理请求的过程中发生意外错误 |
处理404和500状态码时会有点小麻烦,因为这两个错误是由Flask自己生成的,而且一般会返回HTML响应,这很可能会让API客户端困惑
为所有客户端生成适当相应的一种方式是,在错误处理程序中根据客户端请求的格式改写响应,这种技术成为内容协商,
下例是改进后的404错误处理程序,它向Web服务客户端发送JSON格式响应,除此之外都发送HTML格式响应,500错误处理程序的写法类似
app/main/errors.py@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 = 404return responsereturn render_template('404.html'), 404123456789
这个新版错误处理程序检查Accept请求首部(Werkzeug将其编码为request.accept_mimetypes
),根据首部的值决定客户端期望接受的响应格式,浏览器一般不限制响应的格式,所以只为接受JSON格式而不接受HTML格式的客户端生成JSON响应
其他状态码都是由Web服务生成,因此可在蓝本的errors.py
模块作为辅助函数实现,下例是403错误的处理程序,其他错误处理程序的写法类似
# app/api_1_0/errors.pydef forbidden(message):response = jsonify({'error':'forbidden', 'message': message})
response.status_code = 403return response123456
现在,Web服务的视图函数可以调用这些辅助函数生成错误响应了
使用Flask-HTTPAuth认证用户
和普通的Web程序一样,Web服务也需要保护信息,确保未经授权的用户无法访问,为此RIA必须询问用户的登录密令,并将其传给服务器进行验证
REST Web服务的特征之一是无状态,即在服务器在两次请求之间不能“记住”客户端的任何信息,客户端必须在发出的请求中包含所有必要信息,因此所有请求都必须包含用户密令
程序当前的登录功能是在Flask-Login的帮助下实现的,可以把数据存储在用户会话中,默认情况下,Flask把会话保存在客户端cookie中,因此服务器没有保存任何用户相关的信息,都转交给客户端保存,这种实现方式看起来遵守了REST架构的无状态要求,但在REST
Web服务中使用cookie有点不现实,因为Web浏览器之外的客户端很难提供对cookie的支持,鉴于此,使用cookie并不是一个很好的设计选择
REST架构的无状态看起来似乎过于严格,但这并是不随意提出的要求,无状态的服务器伸缩起来更加简单,如果服务器保存了客户端的相关信息,就必须提供一个所有服务器都能访问的共享缓存,这样才能保证一直使用同一台服务器处理特定客户端的请求,这样的需求很难实现
因为REST架构基于HTTP协议,所以发送密令的最佳方式是使用HTTP认证,基本认证和摘要认证都可以,在HTTP认证中,用户密令包含在请求的Authorization首部中
HTTP认证协议很简单,可以直接实现,不过Flask-HTTPAuth拓展提供了一个便利的包装,可以把协议的细节隐藏在装饰器之中,类似于Flask-Login提供的login_required装饰器
Flask-HTTPAuth使用pip安装,在将HTTP基本认证的扩展进行初始化之前,我们先要创建一个HTTPBasicAuth类对象,和Flask-Login一样,Flask-HTTPAuth不对验证用户命令所需的步骤做任何假设,因此所需的信息在回调函数中提供,下例展示了如何初始化Flask-HTTPAuth扩展,以及如何在回调函数中验证密令
# app/api_1_0/authentication.pyfrom flask_httpauth import HTTPBasicAuth
auth = HTTPBasicAuth()@auth.verify_passworddef verify_password(email, password):if email == '':
g.current_user = AnonymousUser()return Trueuser = User.query.filter_by(email = email).first()if not user:return Falseg.current_user = userreturn user.verify_password(password)123456789101112131415
由于这种用户认证方法只在API蓝本中使用,所以Flask-HTTPAuth扩展只能在蓝本包中初始化,而不像其他扩展那样要在程序包中初始化
电子邮件和密码使用User模型中现有的方法验证,如果登录密令正确,这个验证回调函数就返回True,否则返回False,API蓝本也支持匿名用户访问,此时客户端发送的电子邮件字段必须为空
验证回调函数把通过认证的用户保存在Flask的全局对象g中,这样一来,视图函数便能进行访问,注意在匿名登录时,这个函数返回True并把Flask-Login提供的AnonymousUser类实例赋值给g.current_user
由于每次请求时都要传送用户密令,所以API路由最好通过安全的HTTP提供,加密所有的请求和响应
如果认证密令不正确,服务器向客户端返回401错误,默认情况下,Flask-HTTPAuth自动生成这个状态码,但为了和API返回的其他错误保持一致,我们可以自定义这个错误响应:
#app/api_1_0/authentication.py#...@auth.error_headlerdef auth_error():return unauthorized('Invalid credentials')1234567
为了保护路由,可使用装饰器auth.login_required
@api.route('/posts')@auth.login_requireddef get_posts():pass1234
不过,这个蓝本中的所有路由都要使用相同的方式进行保护,所以我们可以在before_request
处理程序中使用一次login_required
装饰器,应用到整个蓝本,如下例所示:
#app/api_1_0/authentication.pyfrom .errors import forbidden@api.before_request@auth.login_requireddef before_request():if not g.current_user.is_anonymous and \not g.current_user.comfirmed:return forbidden('Uncofirmed account')123456789
现在,API蓝本中的所有路由都能进行自动认证,而且作为附加认证,before_request
处理程序还会拒绝已通过认证但没有确认账户的用户
基于令牌的认证
每次请求时,客户端都要发送认证密令,为了避免总是发送敏感信息,我们可以提供一种基于令牌的认证方案
使用基于令牌的认证方案时,客户端要先把登录密令发送给一个特殊的URL,从而生成认证令牌,一旦客户端获得令牌,就可用令牌代替登录密令认证请求,处于安全考虑,令牌有过期时间,令牌过期后,客户端必须重新发送登陆密令以生成新令牌,令牌落入他人之手所带来的安全隐患受限于令牌的短暂使用期限,为了生成和验证认证令牌,我们要在User模型中定义两个新方法,这两个新方法用到了itsdangerous
包,如下
# app/models.pyclass 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})@staticmethoddef verify_auth_token(token):s = Serializer(current_app.config['SECRET_KEY'])try:
data = s.loads(token)except:return Nonereturn User.query.get(data['id'])1234567891011121314151617
generate_auth_token()
方法使用编码后的用户id字段值生成一个签名令牌,还指定了以秒为单位的过期时间,verify_auth_token()
方法接受的参数是一个令牌,如果令牌可用就返回对应的对象,verify_auth_token()
是静态方法,因为只有解码令牌后才能知道用户是谁
为了能够认证包含令牌的请求,我们必须修改Flask-HTTPAuth提供的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 Trueif password == '':
g.current_user = User.verify_auth_token(email_or_token)
g.token_used = Truereturn g.current_user is not Noneuser = User.query.filter_by(email = email_or_token).first()if not user:return Falseg.current_user = user
g.token_used = Falsereturn user.verify_password(password)123456789101112131415161718
在这个新版本中,第一个认证参数可以是电子邮件地址或认证令牌,如果这个参数为空,那就和之前一样,假定是匿名用户,如果密码为空,那就假定email_or_token
参数提供的是令牌,按照令牌的方式进行认证,如果两个参数都不为空,假定使用常规的邮件地址和密码进行认证,在这种实现方式中,基于令牌的认证是可选的,由客户端决定是否使用,为了让视图函数能区分这两种认证方式,我们添加了g.token_used
变量
把认证令牌发送给客户端的路由也要添加到API蓝本中,具体实现如下:
# 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})1234567891011
这个路由也在蓝本中,所以添加到before_request
处理程序上的认证机制也会用在这个路由上,为了避免客户端使用旧令牌申请新令牌,要在视图函数中检查g.token_used
变量的值,如果使用令牌进行认证就拒绝请求,这个视图函数返回JSON格式的响应,其中包含了过期时间为1小时的令牌,JSON格式的响应也包含过期时间