使用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
注意,API包的名字中有个版本号,如果需要创建一个向前兼容的API版本,可以添加一个版本号不同的包,让程序同时支持两个版本的API
在这个API蓝本中,各资源分别在不同的模块中实现,蓝本中还包含处理认证、错误以及提供自定义装饰器的模块,蓝本的构造文件如下所示:
# app/api_1_0/__init__.py
from flask import Blueprint
api = Blueprint('api', __name__)
from . import authentication, posts, users, comments, errors
#...
注册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')
return app
错误处理
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 = 404
return response
return render_template('404.html'), 404
这个新版错误处理程序检查Accept请求首部(Werkzeug将其编码为request.accept_mimetypes
),根据首部的值决定客户端期望接受的响应格式,浏览器一般不限制响应的格式,所以只为接受JSON格式而不接受HTML格式的客户端生成JSON响应
其他状态码都是由Web服务生成,因此可在蓝本的errors.py
模块作为辅助函数实现,下例是403错误的处理程序,其他错误处理程序的写法类似
# app/api_1_0/errors.py
def forbidden(message):
response = jsonify({'error':'forbidden', 'message': message})
response.status_code = 403
return response
现在,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.py
from flask_httpauth import HTTPBasicAuth
auth = HTTPBasicAuth()
@auth.verify_password
def 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蓝本中使用,所以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_headler
def auth_error():
return unauthorized('Invalid credentials')
为了保护路由,可使用装饰器auth.login_required
@api.route('/posts')
@auth.login_required
def get_posts():
pass
不过,这个蓝本中的所有路由都要使用相同的方式进行保护,所以我们可以在before_request
处理程序中使用一次login_required
装饰器,应用到整个蓝本,如下例所示:
#app/api_1_0/authentication.py
from .errors import forbidden
@api.before_request
@auth.login_required
def before_request():
if not g.current_user.is_anonymous and \
not g.current_user.comfirmed:
return forbidden('Uncofirmed account')
现在,API蓝本中的所有路由都能进行自动认证,而且作为附加认证,before_request
处理程序还会拒绝已通过认证但没有确认账户的用户
基于令牌的认证
每次请求时,客户端都要发送认证密令,为了避免总是发送敏感信息,我们可以提供一种基于令牌的认证方案
使用基于令牌的认证方案时,客户端要先把登录密令发送给一个特殊的URL,从而生成认证令牌,一旦客户端获得令牌,就可用令牌代替登录密令认证请求,处于安全考虑,令牌有过期时间,令牌过期后,客户端必须重新发送登陆密令以生成新令牌,令牌落入他人之手所带来的安全隐患受限于令牌的短暂使用期限,为了生成和验证认证令牌,我们要在User模型中定义两个新方法,这两个新方法用到了itsdangerous
包,如下
# 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)
except:
return None
return User.query.get(data['id'])
generate_auth_token()
方法使用编码后的用户id字段值生成一个签名令牌,还指定了以秒为单位的过期时间,verify_auth_token()
方法接受的参数是一个令牌,如果令牌可用就返回对应的对象,verify_auth_token()
是静态方法,因为只有解码令牌后才能知道用户是谁
为了能够认证包含令牌的请求,我们必须修改Flask-HTTPAuth提供的verify_password
回调,除了普通的密令之外,还要接受令牌,修改后的回调函数如下:
# app/api_1_0/authentication.py
@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.current_user = user
g.token_used = False
return user.verify_password(password)
在这个新版本中,第一个认证参数可以是电子邮件地址或认证令牌,如果这个参数为空,那就和之前一样,假定是匿名用户,如果密码为空,那就假定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})
这个路由也在蓝本中,所以添加到before_request
处理程序上的认证机制也会用在这个路由上,为了避免客户端使用旧令牌申请新令牌,要在视图函数中检查g.token_used
变量的值,如果使用令牌进行认证就拒绝请求,这个视图函数返回JSON格式的响应,其中包含了过期时间为1小时的令牌,JSON格式的响应也包含过期时间