本文翻译自The Flask Mega-Tutorial Part XXIII: Application Programming Interfaces (APIs)html
我为此应用程序构建的全部功能都只适用于特定类型的客户端:Web浏览器。 但其余类型的客户端呢? 例如,若是我想构建Android或iOS APP,有两种主流方法能够解决这个问题。 最简单的解决方案是构建一个简单的APP,仅使用一个Web视图组件并用Microblog网站填充整个屏幕,但相比在设备的Web浏览器中打开网站,这种方案几乎没有什么卖点。 一个更好的解决方案(尽管更费力)将是构建一个本地APP,但这个APP如何与仅返回HTML页面的服务器交互呢?git
这就是应用程序编程接口(API)的能力范畴了。 API是一组HTTP路由,被设计为应用程序中的低级入口点。与定义返回HTML以供Web浏览器使用的路由和视图函数不一样,API容许客户端直接使用应用程序的资源,从而决定如何经过客户端彻底地向用户呈现信息。 例如,Microblog中的API能够向用户提供用户信息和用户动态,而且它还能够容许用户编辑现有动态,但仅限于数据级别,不会将此逻辑与HTML混合。github
若是你研究了应用程序中当前定义的全部路由,会注意到其中的几个符合我上面使用的API的定义。 找到它们了吗? 我说的是返回JSON的几条路由,好比第十四章中定义的/translate路由。 这种路由的内容都以JSON格式编码,并在请求时使用POST
方法。 此请求的响应也是JSON格式,服务器仅返回所请求的信息,客户端负责将此信息呈现给用户。数据库
虽然应用程序中的JSON路由具备API的“感受”,但它们的设计初衷是为支持在浏览器中运行的Web应用程序。 设想一下,若是智能手机APP想要使用这些路由,它将没法使用,由于这须要用户登陆,而登陆只能经过HTML表单进行。 在本章中,我将展现如何构建不依赖于Web浏览器的API,而且不会假设链接到它们的客户端的类型。编程
本章的GitHub连接为:Browse, Zip, Diff.json
有些人可能会强烈反对上面提到的/translate和其余JSON路由是API路由。 其余人可能会赞成,但也会认为它们是一个设计糟糕的API。 那么一个精心设计的API有什么特色,为何上面的JSON路由不是一个好的API路由呢?flask
你可能据说过REST API。 REST(Representational State Transfer)是Roy Fielding在博士论文中提出的一种架构。 该架构中,Dr. Fielding以至关抽象和通用的方式展现了REST的六个定义特征。后端
除了Dr.Fielding的论文外,没有关于REST的权威性规范,从而留下了许多细节供读者解读。 一个给定的API是否符合REST规范的话题每每是REST“纯粹主义者”之间激烈争论的源头,REST“纯粹主义者”认为REST API必须以很是明确的方式遵循所有六个特征,而不像REST“实用主义者”那样,仅仅将Dr. Fielding在论文中提出的想法做为指导原则或建议。Dr.Fielding站在纯粹主义阵营的一边,并在博客文章和在线评论中的撰写了一些额外的看法来表达他的愿景。api
目前实施的绝大多数API都遵循“实用主义”的REST实现。 包括来自Facebook,GitHub,Twitter等“大玩家”的大部分API都是如此。不多有公共API被一致认为是纯REST,由于大多数API都没有包含纯粹主义者认为必须实现的某些细节。 尽管Dr. Fielding和其余REST纯粹主义者对评判一个API是不是REST API有严格的规定,但软件行业在实际运用中引用REST是很常见的。浏览器
为了让你了解REST论文中的内容,如下各节将介绍Dr. Fielding列举的六项原则。
客户端-服务器原则至关简单,正如其字面含义,在REST API中,客户端和服务器的角色应该明确区分。 在实践中,这意味着客户端和服务器都是单独的进程,并在大多数状况下,使用基于TCP网络上的HTTP协议进行通讯。
分层系统原则是说当客户端须要与服务器通讯时,它可能最终链接到代理服务器而不是实际的服务器。 所以,对于客户端来讲,若是不直接链接到服务器,它发送请求的方式应该没有什么区别,事实上,它甚至可能不知道它是否链接到目标服务器。 一样,这个原则规定服务器兼容直接接收来自代理服务器的请求,因此它毫不能假设链接的另外一端必定是客户端。
这是REST的一个重要特性,由于可以添加中间节点的这个特性,容许应用程序架构师使用负载均衡器,缓存,代理服务器等来设计知足大量请求的大型复杂网络。
该原则扩展了分层系统,经过明确指出容许服务器或代理服务器缓存频繁且相同请求的响应内容以提升系统性能。 有一个你可能熟悉的缓存实现:全部Web浏览器中的缓存。 Web浏览器缓存层一般用于避免一遍又一遍地请求相同的文件,例如图像。
为了达到API的目的,目标服务器须要经过使用缓存控制来指示响应是否能够在代理服务器传回客户端时进行缓存。 请注意,因为安全缘由,部署到生产环境的API必须使用加密,所以,除非此代理服务器terminates SSL链接,或者执行解密和从新加密,不然缓存一般不会在代理服务器中完成。
这是一项可选要求,规定服务器能够提供可执行代码以响应客户端,这样一来,就能够从服务器上获取客户端的新功能。 由于这个原则须要服务器和客户端之间就客户端可以运行的可执行代码类型达成一致,因此这在API中不多使用。 你可能会认为服务器可能会返回JavaScript代码以供Web浏览器客户端执行,但REST并不是专门针对Web浏览器客户端而设计。 例如,若是客户端是iOS或Android设备,执行JavaScript可能会带来一些复杂状况。
无状态原则是REST纯粹主义者和实用主义者之间争论最多的两个中心之一。 它指出,REST API不该保存客户端发送请求时的任何状态。 这意味着,在Web开发中常见的机制都不能在用户浏览应用程序页面时“记住”用户。 在无状态API中,每一个请求都须要包含服务器须要识别和验证客户端并执行请求的信息。这也意味着服务器没法在数据库或其余存储形式中存储与客户端链接有关的任何数据。
若是你想知道为何REST须要无状态服务器,主要缘由是无状态服务器很是容易扩展,你只需在负载均衡器后面运行多个服务器实例便可。 若是服务器存储客户端状态,则事情会变得更复杂,由于你必须弄清楚多个服务器如何访问和更新该状态,或者确保给定客户端始终由同一服务器处理,这样的机制一般称为粘性会话。
再思考一下本章介绍中讨论的/translate路由,就会发现它不能被视为RESTful,由于与该路由相关的视图函数依赖于Flask-Login的@login_required
装饰器, 这会将用户的登陆状态存储在Flask用户会话中。
最后,最重要的,最有争议的,最含糊不清的REST原则是统一接口。 Dr. Fielding列举了REST统一接口的四个特性:惟一资源标识符,资源表示,自描述性消息和超媒体。
惟一资源标识符是经过为每一个资源分配惟一的URL来实现的。 例如,与给定用户关联的URL能够是/api/users/,其中是在数据库表主键中分配给用户的标识符。 大多数API都能很好地实现这一点。
资源表示的使用意味着当服务器和客户端交换关于资源的信息时,他们必须使用商定的格式。 对于大多数现代API,JSON格式用于构建资源表示。 API能够选择支持多种资源表示格式,而且在这种状况下,HTTP协议中的内容协商选项是客户端和服务器确认格式的机制。
自描述性消息意味着在客户端和服务器之间交换的请求和响应必须包含对方须要的全部信息。 做为一个典型的例子,HTTP请求方法用于指示客户端但愿服务器执行的操做。 GET
请求表示客户想要检索资源信息,POST
请求表示客户想要建立新资源,PUT
或PATCH
请求定义对现有资源的修改,DELETE
表示删除资源的请求。 目标资源被指定为请求的URL,并在HTTP头,URL的查询字符串部分或请求主体中提供附加信息。
超媒体需求是最具争议性的,并且不多有API实现,而那些实现它的API不多以知足REST纯粹主义者的方式进行。因为应用程序中的资源都是相互关联的,所以此要求会要求将这些关系包含在资源表示中,以便客户端能够经过遍历关系来发现新资源,这几乎与你在Web应用程序中经过点击从一个页面到另外一个页面的连接来发现新页面的方式相同。理想状况下,客户端能够输入一个API,而不须要任何有关其中的资源的信息,就能够简单地经过超媒体连接来了解它们。可是,与HTML和XML不一样,一般用于API中资源表示的JSON格式没有定义包含连接的标准方式,所以你不得不使用自定义结构,或者相似JSON-API,HAL, JSON-LD这样的试图解决这种差距的JSON扩展之一。
为了让你体验开发API所涉及的内容,我将在Microblog添加API。 我不会实现全部的API,只会实现与用户相关的全部功能,并将其余资源(如用户动态)的实现留给读者做为练习。
为了保持组织有序,并遵循我在第十五章中描述的结构, 我将建立一个包含全部API路由的新blueprint。 因此,让咱们从建立blueprint所在的目录开始:
1 (venv) $ mkdir app/api
在blueprint的__init__.py
文件中建立blueprint对象,这与应用程序中的其余blueprint相似:
app/api/__init__.py
: API blueprint 构造器。
1 from flask import Blueprint 2 3 bp = Blueprint('api', __name__) 4 5 from app.api import users, errors, tokens
你可能会记得有时须要将导入移动到底部以免循环依赖错误。 这就是为何app/api/users.py,app/api/errors.py和app/api/tokens.py模块(我尚未写)在blueprint建立以后导入的缘由。
API的主要内容将存储在app/api/users.py模块中。 下表总结了我要实现的路由:
HTTP 方法 | 资源 URL | 注释 |
---|---|---|
GET |
/api/users/ | 返回一个用户 |
GET |
/api/users | 返回全部用户的集合 |
GET |
/api/users//followers | 返回某个用户的粉丝集合 |
GET |
/api/users//followed | 返回某个用户关注的用户集合 |
POST |
/api/users | 注册一个新用户 |
PUT |
/api/users/ | 修改某个用户 |
如今我要建立一个模块的框架,其中使用占位符来暂时填充全部的路由:
app/api/users.py:用户API资源占位符。
1 from app.api import bp 2 3 @bp.route('/users/<int:id>', methods=['GET']) 4 def get_user(id): 5 pass 6 7 @bp.route('/users', methods=['GET']) 8 def get_users(): 9 pass 10 11 @bp.route('/users/<int:id>/followers', methods=['GET']) 12 def get_followers(id): 13 pass 14 15 @bp.route('/users/<int:id>/followed', methods=['GET']) 16 def get_followed(id): 17 pass 18 19 @bp.route('/users', methods=['POST']) 20 def create_user(): 21 pass 22 23 @bp.route('/users/<int:id>', methods=['PUT']) 24 def update_user(id): 25 pass
app/api/errors.py模块将定义一些处理错误响应的辅助函数。 但如今,我使用占位符,并将在以后填充内容:
app/api/errors.py:错误处理占位符。
1 def bad_request(): 2 pass
app/api/tokens.py是将要定义认证子系统的模块。 它将为非Web浏览器登陆的客户端提供另外一种方式。如今,我也使用占位符来处理该模块:
app/api/tokens.py: Token处理占位符。
1 def get_token(): 2 pass 3 4 def revoke_token(): 5 pass
新的API blueprint须要在应用工厂函数中注册:
app/__init__.py
:应用中注册API blueprint。
1 # ... 2 3 def create_app(config_class=Config): 4 app = Flask(__name__) 5 6 # ... 7 8 from app.api import bp as api_bp 9 app.register_blueprint(api_bp, url_prefix='/api') 10 11 # ...
实施API时要考虑的第一个方面是决定其资源表示形式。 我要实现一个用户类型的API,所以我须要决定的是用户资源的表示形式。 通过一番头脑风暴,得出了如下JSON表示形式:
1 { 2 "id": 123, 3 "username": "susan", 4 "password": "my-password", 5 "email": "susan@example.com", 6 "last_seen": "2017-10-20T15:04:27Z", 7 "about_me": "Hello, my name is Susan!", 8 "post_count": 7, 9 "follower_count": 35, 10 "followed_count": 21, 11 "_links": { 12 "self": "/api/users/123", 13 "followers": "/api/users/123/followers", 14 "followed": "/api/users/123/followed", 15 "avatar": "https://www.gravatar.com/avatar/..." 16 } 17 }
许多字段直接来自用户数据库模型。 password
字段的特殊之处在于,它仅在注册新用户时才会使用。 回顾第五章,用户密码不存储在数据库中,只存储一个散列字符串,因此密码永远不会被返回。email
字段也被专门处理,由于我不想公开用户的电子邮件地址。 只有当用户请求本身的条目时,才会返回email
字段,可是当他们检索其余用户的条目时不会返回。post_count
,follower_count
和followed_count
字段是“虚拟”字段,它们在数据库字段中不存在,提供给客户端是为了方便。 这是一个很好的例子,它演示了资源表示不须要和服务器中资源的实际定义一致。
请注意_links
部分,它实现了超媒体要求。 定义的连接包括指向当前资源的连接,用户的粉丝列表连接,用户关注的用户列表连接,最后是指向用户头像图像的连接。 未来,若是我决定向这个API添加用户动态,那么用户的动态列表连接也应包含在这里。
JSON格式的一个好处是,它老是转换为Python字典或列表的表示形式。 Python标准库中的json
包负责Python数据结构和JSON之间的转换。所以,为了生成这些表示,我将在User
模型中添加一个名为to_dict()
的方法,该方法返回一个Python字典:
app/models.py:User模型转换成表示。
1 from flask import url_for 2 # ... 3 4 class User(UserMixin, db.Model): 5 # ... 6 7 def to_dict(self, include_email=False): 8 data = { 9 'id': self.id, 10 'username': self.username, 11 'last_seen': self.last_seen.isoformat() + 'Z', 12 'about_me': self.about_me, 13 'post_count': self.posts.count(), 14 'follower_count': self.followers.count(), 15 'followed_count': self.followed.count(), 16 '_links': { 17 'self': url_for('api.get_user', id=self.id), 18 'followers': url_for('api.get_followers', id=self.id), 19 'followed': url_for('api.get_followed', id=self.id), 20 'avatar': self.avatar(128) 21 } 22 } 23 if include_email: 24 data['email'] = self.email 25 return data
该方法一目了然,只是简单地生成并返回用户表示的字典。正如我上面提到的那样,email
字段须要特殊处理,由于我只想在用户请求本身的数据时才包含电子邮件。 因此我使用include_email
标志来肯定该字段是否包含在表示中。
注意一下last_seen
字段的生成。 对于日期和时间字段,我将使用ISO 8601格式,Python的datetime
对象能够经过isoformat()
方法生成这样格式的字符串。 可是由于我使用的datetime
对象的时区是UTC,且但没有在其状态中记录时区,因此我须要在末尾添加Z
,即ISO 8601的UTC时区代码。
最后,看看我如何实现超媒体连接。 对于指向应用其余路由的三个连接,我使用url_for()
生成URL(目前指向我在app/api/users.py中定义的占位符视图函数)。 头像连接是特殊的,由于它是应用外部的Gravatar URL。 对于这个连接,我使用了与渲染网页中的头像的相同avatar()
方法。
to_dict()
方法将用户对象转换为Python表示,之后会被转换为JSON。 我还须要其反向处理的方法,即客户端在请求中传递用户表示,服务器须要解析并将其转换为User
对象。 如下是实现从Python字典到User
对象转换的from_dict()
方法:
app/models.py:表示转换成User模型。
1 class User(UserMixin, db.Model): 2 # ... 3 4 def from_dict(self, data, new_user=False): 5 for field in ['username', 'email', 'about_me']: 6 if field in data: 7 setattr(self, field, data[field]) 8 if new_user and 'password' in data: 9 self.set_password(data['password'])
本处我决定使用循环来导入客户端能够设置的任何字段,即username
,email
和about_me
。 对于每一个字段,我检查它是否存在于data
参数中,若是存在,我使用Python的setattr()
在对象的相应属性中设置新值。
password
字段被视为特例,由于它不是对象中的字段。 new_user
参数肯定了这是不是新的用户注册,这意味着data
中包含password
。 要在用户模型中设置密码,须要调用set_password()
方法来建立密码哈希。
除了使用单个资源表示形式外,此API还须要一组用户的表示。 例如客户请求用户或粉丝列表时使用的格式。 如下是一组用户的表示:
1 { 2 "items": [ 3 { ... user resource ... }, 4 { ... user resource ... }, 5 ... 6 ], 7 "_meta": { 8 "page": 1, 9 "per_page": 10, 10 "total_pages": 20, 11 "total_items": 195 12 }, 13 "_links": { 14 "self": "http://localhost:5000/api/users?page=1", 15 "next": "http://localhost:5000/api/users?page=2", 16 "prev": null 17 } 18 }
在这个表示中,items
是用户资源的列表,每一个用户资源的定义如前一节所述。 _meta
部分包含集合的元数据,客户端在向用户渲染分页控件时就会用得上。 _links
部分定义了相关连接,包括集合自己的连接以及上一页和下一页连接,也能帮助客户端对列表进行分页。
因为分页逻辑,生成用户集合的表示很棘手,可是该逻辑对于我未来可能要添加到此API的其余资源来讲是一致的,因此我将以通用的方式实现它,以便适用于其余模型。 能够回顾第十六章,就会发现我目前的状况与全文索引相似,都是实现一个功能,还要让它能够应用于任何模型。 对于全文索引,我使用的解决方案是实现一个SearchableMixin
类,任何须要全文索引的模型均可以从中继承。 我会故技重施,实现一个新的mixin类,我命名为PaginatedAPIMixin
:
app/models.py:分页表示mixin类。
1 class PaginatedAPIMixin(object): 2 @staticmethod 3 def to_collection_dict(query, page, per_page, endpoint, **kwargs): 4 resources = query.paginate(page, per_page, False) 5 data = { 6 'items': [item.to_dict() for item in resources.items], 7 '_meta': { 8 'page': page, 9 'per_page': per_page, 10 'total_pages': resources.pages, 11 'total_items': resources.total 12 }, 13 '_links': { 14 'self': url_for(endpoint, page=page, per_page=per_page, 15 **kwargs), 16 'next': url_for(endpoint, page=page + 1, per_page=per_page, 17 **kwargs) if resources.has_next else None, 18 'prev': url_for(endpoint, page=page - 1, per_page=per_page, 19 **kwargs) if resources.has_prev else None 20 } 21 } 22 return data
to_collection_dict()
方法产生一个带有用户集合表示的字典,包括items
,_meta
和_links
部分。 你可能须要仔细检查该方法以了解其工做原理。 前三个参数是Flask-SQLAlchemy查询对象,页码和每页数据数量。 这些是决定要返回的条目是什么的参数。 该实现使用查询对象的paginate()
方法来获取该页的条目,就像我对主页,发现页和我的主页中的用户动态所作的同样。
复杂的部分是生成连接,其中包括自引用以及指向下一页和上一页的连接。 我想让这个函数具备通用性,因此我不能使用相似url_for('api.get_users', id=id, page=page)
这样的代码来生成自连接(译者注:由于这样就固定成用户资源专用了)。 url_for()
的参数将取决于特定的资源集合,因此我将依赖于调用者在endpoint
参数中传递的值,来肯定须要发送到url_for()
的视图函数。 因为许多路由都须要参数,我还须要在kwargs
中捕获更多关键字参数,并将它们传递给url_for()
。 page
和per_page
查询字符串参数是明确给出的,由于它们控制全部API路由的分页。
这个mixin类须要做为父类添加到User模型中:
app/models.py:添加PaginatedAPIMixin到User模型中。
1 class User(PaginatedAPIMixin, UserMixin, db.Model): 2 # ...
将集合转换成json表示,不须要反向操做,由于我不须要客户端发送用户列表到服务器。
我在第七章中定义的错误页面仅适用于使用Web浏览器的用户。当一个API须要返回一个错误时,它须要是一个“机器友好”的错误类型,以便客户端能够轻松解释这些错误。 所以,我一样设计错误的表示为一个JSON。 如下是我要使用的基本结构:
1 { 2 "error": "short error description", 3 "message": "error message (optional)" 4 }
除了错误的有效载荷以外,我还会使用HTTP协议的状态代码来指示常见错误的类型。 为了帮助我生成这些错误响应,我将在app/api/errors.py中写入error_response()
函数:
app/api/errors.py:错误响应。
1 from flask import jsonify 2 from werkzeug.http import HTTP_STATUS_CODES 3 4 def error_response(status_code, message=None): 5 payload = {'error': HTTP_STATUS_CODES.get(status_code, 'Unknown error')} 6 if message: 7 payload['message'] = message 8 response = jsonify(payload) 9 response.status_code = status_code 10 return response
该函数使用来自Werkzeug(Flask的核心依赖项)的HTTP_STATUS_CODES
字典,它为每一个HTTP状态代码提供一个简短的描述性名称。 我在错误表示中使用这些名称做为error
字段的值,因此我只须要操心数字状态码和可选的长描述。 jsonify()
函数返回一个默认状态码为200的FlaskResponse
对象,所以在建立响应以后,我将状态码设置为对应的错误代码。
API将返回的最多见错误将是代码400,表明了“错误的请求”。 这是客户端发送请求中包含无效数据的错误。 为了更容易产生这个错误,我将为它添加一个专用函数,只需传入长的描述性消息做为参数就能够调用。 下面是我以前添加的bad_request()
占位符:
app/api/errors.py:错误请求的响应。
1 # ... 2 3 def bad_request(message): 4 return error_response(400, message)
必需的用户JSON表示的支持已完成,所以我已准备好开始对API endpoint进行编码了。
让咱们就从使用给定的id
来检索指定用户开始吧:
app/api/users.py:返回一个用户。
1 from flask import jsonify 2 from app.models import User 3 4 @bp.route('/users/<int:id>', methods=['GET']) 5 def get_user(id): 6 return jsonify(User.query.get_or_404(id).to_dict())
视图函数接收被请求用户的id
做为URL中的动态参数。 查询对象的get_or_404()
方法是之前见过的get()
方法的一个很是有用的变体,若是用户存在,它返回给定id
的对象,当id不存在时,它会停止请求并向客户端返回一个404错误,而不是返回None
。 get_or_404()
比get()
更有优点,它不须要检查查询结果,简化了视图函数中的逻辑。
我添加到User的to_dict()
方法用于生成用户资源表示的字典,而后Flask的jsonify()
函数将该字典转换为JSON格式的响应以返回给客户端。
若是你想查看第一条API路由的工做原理,请启动服务器,而后在浏览器的地址栏中输入如下URL:
1 http://localhost:5000/api/users/1
浏览器会以JSON格式显示第一个用户。 也尝试使用大一些的id
值来查看SQLAlchemy查询对象的get_or_404()
方法如何触发404错误(我将在稍后向你演示如何扩展错误处理,以便返回这些错误 JSON格式)。
为了测试这条新路由,我将安装HTTPie,这是一个用Python编写的命令行HTTP客户端,能够轻松发送API请求:
(venv) $ pip install httpie
我如今能够请求id
为1
的用户(多是你本身),命令以下:
1 (venv) $ http GET http://localhost:5000/api/users/1 2 HTTP/1.0 200 OK 3 Content-Length: 457 4 Content-Type: application/json 5 Date: Mon, 27 Nov 2017 20:19:01 GMT 6 Server: Werkzeug/0.12.2 Python/3.6.3 7 8 { 9 "_links": { 10 "avatar": "https://www.gravatar.com/avatar/993c...2724?d=identicon&s=128", 11 "followed": "/api/users/1/followed", 12 "followers": "/api/users/1/followers", 13 "self": "/api/users/1" 14 }, 15 "about_me": "Hello! I'm the author of the Flask Mega-Tutorial.", 16 "followed_count": 0, 17 "follower_count": 1, 18 "id": 1, 19 "last_seen": "2017-11-26T07:40:52.942865Z", 20 "post_count": 10, 21 "username": "miguel" 22 }
要返回全部用户的集合,我如今能够依靠PaginatedAPIMixin
的to_collection_dict()
方法:
app/api/users.py:返回全部用户的集合。
1 from flask import request 2 3 @bp.route('/users', methods=['GET']) 4 def get_users(): 5 page = request.args.get('page', 1, type=int) 6 per_page = min(request.args.get('per_page', 10, type=int), 100) 7 data = User.to_collection_dict(User.query, page, per_page, 'api.get_users') 8 return jsonify(data)
对于这个实现,我首先从请求的查询字符串中提取page
和per_page
,若是它们没有被定义,则分别使用默认值1和10。 per_page
具备额外的逻辑,以100为上限。 给客户端控件请求太大的页面并非一个好主意,由于这可能会致使服务器的性能问题。 而后page
和per_page
以及query对象(在本例中,该查询只是User.query
,是返回全部用户的最通用的查询)参数被传递给to_collection_query()
方法。 最后一个参数是api.get_users
,这是我在表示中使用的三个连接所需的endpoint名称。
要使用HTTPie测试此endpoint,请使用如下命令:
1 (venv) $ http GET http://localhost:5000/api/users
接下来的两个endpoint是返回粉丝集合和关注用户集合。 与上面的很是类似:
app/api/users.py:返回粉丝列表和关注用户列表。
1 @bp.route('/users/<int:id>/followers', methods=['GET']) 2 def get_followers(id): 3 user = User.query.get_or_404(id) 4 page = request.args.get('page', 1, type=int) 5 per_page = min(request.args.get('per_page', 10, type=int), 100) 6 data = User.to_collection_dict(user.followers, page, per_page, 7 'api.get_followers', id=id) 8 return jsonify(data) 9 10 @bp.route('/users/<int:id>/followed', methods=['GET']) 11 def get_followed(id): 12 user = User.query.get_or_404(id) 13 page = request.args.get('page', 1, type=int) 14 per_page = min(request.args.get('per_page', 10, type=int), 100) 15 data = User.to_collection_dict(user.followed, page, per_page, 16 'api.get_followed', id=id) 17 return jsonify(data)
因为这两条路由是特定于用户的,所以它们具备id
动态参数。 id
用于从数据库中获取用户,而后将user.followers
和user.followed
关系查询提供给to_collection_dict()
,因此但愿如今你能够看到,花费一点点额外的时间,并以通用的方式设计该方法,对于得到的回报而言是值得的。 to_collection_dict()
的最后两个参数是endpoint名称和id
,id
将在kwargs
中做为一个额外关键字参数,而后在生成连接时将它传递给url_for()
。
和前面的示例相似,你可使用HTTPie来测试这两个路由,以下所示:
1 (venv) $ http GET http://localhost:5000/api/users/1/followers 2 (venv) $ http GET http://localhost:5000/api/users/1/followed
因为超媒体,你不须要记住这些URL,由于它们包含在用户表示的_links
部分。
/users路由的POST
请求将用于注册新的用户账户。 你能够在下面看到这条路由的实现:
app/api/users.py:注册新用户。
1 from flask import url_for 2 from app import db 3 from app.api.errors import bad_request 4 5 @bp.route('/users', methods=['POST']) 6 def create_user(): 7 data = request.get_json() or {} 8 if 'username' not in data or 'email' not in data or 'password' not in data: 9 return bad_request('must include username, email and password fields') 10 if User.query.filter_by(username=data['username']).first(): 11 return bad_request('please use a different username') 12 if User.query.filter_by(email=data['email']).first(): 13 return bad_request('please use a different email address') 14 user = User() 15 user.from_dict(data, new_user=True) 16 db.session.add(user) 17 db.session.commit() 18 response = jsonify(user.to_dict()) 19 response.status_code = 201 20 response.headers['Location'] = url_for('api.get_user', id=user.id) 21 return response
该请求将接受请求主体中提供的来自客户端的JSON格式的用户表示。 Flask提供request.get_json()
方法从请求中提取JSON并将其做为Python结构返回。 若是在请求中没有找到JSON数据,该方法返回None
,因此我可使用表达式request.get_json() or {}
确保我老是能够得到一个字典。
在我可使用这些数据以前,我须要确保我已经掌握了全部信息,所以我首先检查是否包含三个必填字段,username
, email
和password
。 若是其中任何一个缺失,那么我使用app/api/errors.py模块中的bad_request()
辅助函数向客户端返回一个错误。 除此以外,我还须要确保username
和email
字段还没有被其余用户使用,所以我尝试使用得到的用户名和电子邮件从数据库中加载用户,若是返回了有效的用户,那么我也将返回错误给客户端。
一旦经过了数据验证,我能够轻松建立一个用户对象并将其添加到数据库中。 为了建立用户,我依赖User
模型中的from_dict()
方法,new_user
参数被设置为True
,因此它也接受一般不存在于用户表示中的password
字段。
我为这个请求返回的响应将是新用户的表示,因此使用to_dict()
产生它的有效载荷。 建立资源的POST
请求的响应状态代码应该是201,即建立新实体时使用的代码。 此外,HTTP协议要求201响应包含一个值为新资源URL的Location
头部。
下面你能够看到如何经过HTTPie从命令行注册一个新用户:
1 (venv) $ http POST http://localhost:5000/api/users username=alice password=dog \ 2 email=alice@example.com "about_me=Hello, my name is Alice!"
示例API中使用的最后一个endpoint用于修改已存在的用户:
app/api/users.py:修改用户。
1 @bp.route('/users/<int:id>', methods=['PUT']) 2 def update_user(id): 3 user = User.query.get_or_404(id) 4 data = request.get_json() or {} 5 if 'username' in data and data['username'] != user.username and \ 6 User.query.filter_by(username=data['username']).first(): 7 return bad_request('please use a different username') 8 if 'email' in data and data['email'] != user.email and \ 9 User.query.filter_by(email=data['email']).first(): 10 return bad_request('please use a different email address') 11 user.from_dict(data, new_user=False) 12 db.session.commit() 13 return jsonify(user.to_dict())
一个请求到来,我经过URL收到一个动态的用户id
,因此我能够加载指定的用户或返回404错误(若是找不到)。 就像注册新用户同样,我须要验证客户端提供的username
和email
字段是否与其余用户发生了冲突,但在这种状况下,验证有点棘手。 首先,这些字段在此请求中是可选的,因此我须要检查字段是否存在。 第二个复杂因素是客户端可能提供与目前字段相同的值,因此在检查用户名或电子邮件是否被采用以前,我须要确保它们与当前的不一样。 若是任何验证检查失败,那么我会像以前同样返回400错误给客户端。
一旦数据验证经过,我可使用User
模型的from_dict()
方法导入客户端提供的全部数据,而后将更改提交到数据库。 该请求的响应会将更新后的用户表示返回给用户,并使用默认的200状态代码。
如下是一个示例请求,它用HTTPie编辑about_me
字段:
1 (venv) $ http PUT http://localhost:5000/api/users/2 "about_me=Hi, I am Miguel"
我在前一节中添加的API endpoint当前对任何客户端都是开放的。 显然,执行这些操做须要认证用户才安全,为此我须要添加认证和受权,简称“AuthN”和“AuthZ”。 思路是,客户端发送的请求提供了某种标识,以便服务器知道客户端表明的是哪位用户,而且能够验证是否容许该用户执行请求的操做。
保护这些API endpoint的最明显的方法是使用Flask-Login中的@login_required
装饰器,可是这种方法存在一些问题。 装饰器检测到未经过身份验证的用户时,会将用户重定向到HTML登陆页面。 在API中没有HTML或登陆页面的概念,若是客户端发送带有无效或缺乏凭证的请求,服务器必须拒绝请求并返回401状态码。 服务器不能假定API客户端是Web浏览器,或者它能够处理重定向,或者它能够渲染和处理HTML登陆表单。 当API客户端收到401状态码时,它知道它须要向用户询问凭证,可是它是如何实现的,服务器不须要关心。
对于API身份验证需求,我将使用token身份验证方案。 当客户端想要开始与API交互时,它须要使用用户名和密码进行验证,而后得到一个临时token。 只要token有效,客户端就能够发送附带token的API请求以经过认证。 一旦token到期,须要请求新的token。 为了支持用户token,我将扩展User
模型:
app/models.py:支持用户token。
1 import base64 2 from datetime import datetime, timedelta 3 import os 4 5 class User(UserMixin, PaginatedAPIMixin, db.Model): 6 # ... 7 token = db.Column(db.String(32), index=True, unique=True) 8 token_expiration = db.Column(db.DateTime) 9 10 # ... 11 12 def get_token(self, expires_in=3600): 13 now = datetime.utcnow() 14 if self.token and self.token_expiration > now + timedelta(seconds=60): 15 return self.token 16 self.token = base64.b64encode(os.urandom(24)).decode('utf-8') 17 self.token_expiration = now + timedelta(seconds=expires_in) 18 db.session.add(self) 19 return self.token 20 21 def revoke_token(self): 22 self.token_expiration = datetime.utcnow() - timedelta(seconds=1) 23 24 @staticmethod 25 def check_token(token): 26 user = User.query.filter_by(token=token).first() 27 if user is None or user.token_expiration < datetime.utcnow(): 28 return None 29 return user
我为用户模型添加了一个token
属性,而且由于我须要经过它搜索数据库,因此我为它设置了惟一性和索引。 我还添加了token_expiration
字段,它保存token过时的日期和时间。 这使得token不会长时间有效,以避免成为安全风险。
我建立了三种方法来处理这些token。 get_token()
方法为用户返回一个token。 以base64编码的24位随机字符串来生成这个token,以便全部字符都处于可读字符串范围内。 在建立新token以前,此方法会检查当前分配的token在到期以前是否至少还剩一分钟,而且在这种状况下会返回现有的token。
使用token时,有一个策略能够当即使token失效老是一件好事,而不是仅依赖到期日期。 这是一个常常被忽视的安全最佳实践。 revoke_token()
方法使得当前分配给用户的token失效,只需设置到期时间为当前时间的前一秒。
check_token()
方法是一个静态方法,它将一个token做为参数传入并返回此token所属的用户。 若是token无效或过时,则该方法返回None
。
因为我对数据库进行了更改,所以须要生成新的数据库迁移,而后使用它升级数据库:
1 (venv) $ flask db migrate -m "user tokens" 2 (venv) $ flask db upgrade
当你编写一个API时,你必须考虑到你的客户端并不老是要链接到Web应用程序的Web浏览器。 当独立客户端(如智能手机APP)甚至是基于浏览器的单页应用程序访问后端服务时,API展现力量的机会就来了。 当这些专用客户端须要访问API服务时,他们首先须要请求token,对应传统Web应用程序中登陆表单的部分。
为了简化使用token认证时客户端和服务器之间的交互,我将使用名为Flask-HTTPAuth的Flask插件。 Flask-HTTPAuth可使用pip安装:
1 (venv) $ pip install flask-httpauth
Flask-HTTPAuth支持几种不一样的认证机制,都对API友好。 首先,我将使用HTTPBasic Authentication,该机制要求客户端在标准的Authorization头部中附带用户凭证。 要与Flask-HTTPAuth集成,应用须要提供两个函数:一个用于检查用户提供的用户名和密码,另外一个用于在认证失败的状况下返回错误响应。这些函数经过装饰器在Flask-HTTPAuth中注册,而后在认证流程中根据须要由插件自动调用。 实现以下:
app/api/auth.py:基本认证支持。
1 from flask import g 2 from flask_httpauth import HTTPBasicAuth 3 from app.models import User 4 from app.api.errors import error_response 5 6 basic_auth = HTTPBasicAuth() 7 8 @basic_auth.verify_password 9 def verify_password(username, password): 10 user = User.query.filter_by(username=username).first() 11 if user is None: 12 return False 13 g.current_user = user 14 return user.check_password(password) 15 16 @basic_auth.error_handler 17 def basic_auth_error(): 18 return error_response(401)
Flask-HTTPAuth的HTTPBasicAuth
类实现了基本的认证流程。 这两个必需的函数分别经过verify_password
和error_handler
装饰器进行注册。
验证函数接收客户端提供的用户名和密码,若是凭证有效则返回True
,不然返回False
。 我依赖User
类的check_password()
方法来检查密码,它在Web应用的认证过程当中,也会被Flask-Login使用。 我将认证用户保存在g.current_user
中,以便我能够从API视图函数中访问它。
错误处理函数只返回由app/api/errors.py模块中的error_response()
函数生成的401错误。 401错误在HTTP标准中定义为“未受权”错误。 HTTP客户端知道当它们收到这个错误时,须要从新发送有效的凭证。
如今我已经实现了基本认证的支持,所以我能够添加一条token检索路由,以便客户端在须要token时调用:
app/api/tokens.py:生成用户token。
1 from flask import jsonify, g 2 from app import db 3 from app.api import bp 4 from app.api.auth import basic_auth 5 6 @bp.route('/tokens', methods=['POST']) 7 @basic_auth.login_required 8 def get_token(): 9 token = g.current_user.get_token() 10 db.session.commit() 11 return jsonify({'token': token})
这个视图函数使用了HTTPBasicAuth
实例中的@basic_auth.login_required
装饰器,它将指示Flask-HTTPAuth验证身份(经过我上面定义的验证函数),而且仅当提供的凭证是有效的才运行下面的视图函数。 该视图函数的实现依赖于用户模型的get_token()
方法来生成token。 数据库提交在生成token后发出,以确保token及其到期时间被写回到数据库。
若是你尝试直接向token API路由发送POST请求,则会发生如下状况:
1 (venv) $ http POST http://localhost:5000/api/tokens 2 HTTP/1.0 401 UNAUTHORIZED 3 Content-Length: 30 4 Content-Type: application/json 5 Date: Mon, 27 Nov 2017 20:01:00 GMT 6 Server: Werkzeug/0.12.2 Python/3.6.3 7 WWW-Authenticate: Basic realm="Authentication Required" 8 9 { 10 "error": "Unauthorized" 11 }
HTTP响应包括401状态码和我在basic_auth_error()
函数中定义的错误负载。 下面请求带上了基本认证须要的凭证:
1 (venv) $ http --auth <username>:<password> POST http://localhost:5000/api/tokens 2 HTTP/1.0 200 OK 3 Content-Length: 50 4 Content-Type: application/json 5 Date: Mon, 27 Nov 2017 20:01:22 GMT 6 Server: Werkzeug/0.12.2 Python/3.6.3 7 8 { 9 "token": "pC1Nu9wwyNt8VCj1trWilFdFI276AcbS" 10 }
如今状态码是200,这是成功请求的代码,而且有效载荷包括用户的token。 请注意,当你发送这个请求时,你须要用你本身的凭证来替换<username>:<password>
。 用户名和密码须要以冒号做为分隔符。
客户端如今能够请求一个token来和API endpoint一块儿使用,因此剩下的就是向这些endpoint添加token验证。 Flask-HTTPAuth也能够为我处理的这些事情。 我须要建立基于HTTPTokenAuth
类的第二个身份验证明例,并提供token验证回调:
app/api/auth.py: Token认证支持。
1 # ... 2 from flask_httpauth import HTTPTokenAuth 3 4 # ... 5 token_auth = HTTPTokenAuth() 6 7 # ... 8 9 @token_auth.verify_token 10 def verify_token(token): 11 g.current_user = User.check_token(token) if token else None 12 return g.current_user is not None 13 14 @token_auth.error_handler 15 def token_auth_error(): 16 return error_response(401)
使用token认证时,Flask-HTTPAuth使用的是verify_token
装饰器注册验证函数,除此以外,token认证的工做方式与基本认证相同。 个人token验证函数使用User.check_token()
来定位token所属的用户。 该函数还经过将当前用户设置为None
来处理缺失token的状况。返回值是True
仍是False
,决定了Flask-HTTPAuth是否容许视图函数的运行。
为了使用token保护API路由,须要添加@token_auth.login_required
装饰器:
app/api/users.py:使用token认证保护用户路由。
1 from app.api.auth import token_auth 2 3 @bp.route('/users/<int:id>', methods=['GET']) 4 @token_auth.login_required 5 def get_user(id): 6 # ... 7 8 @bp.route('/users', methods=['GET']) 9 @token_auth.login_required 10 def get_users(): 11 # ... 12 13 @bp.route('/users/<int:id>/followers', methods=['GET']) 14 @token_auth.login_required 15 def get_followers(id): 16 # ... 17 18 @bp.route('/users/<int:id>/followed', methods=['GET']) 19 @token_auth.login_required 20 def get_followed(id): 21 # ... 22 23 @bp.route('/users', methods=['POST']) 24 def create_user(): 25 # ... 26 27 @bp.route('/users/<int:id>', methods=['PUT']) 28 @token_auth.login_required 29 def update_user(id): 30 # ...
请注意,装饰器被添加到除create_user()
以外的全部API视图函数中,显而易见,这个函数不能使用token认证,由于用户都不存在时,更不会有token了。
若是你直接对上面列出的受token保护的endpoint发起请求,则会获得一个401错误。为了成功访问,你须要添加Authorization
头部,其值是请求/api/tokens得到的token的值。Flask-HTTPAuth指望的是”不记名”token,可是它没有被HTTPie直接支持。就像针对基本认证,HTTPie提供了--auth
选项来接受用户名和密码,可是token的头部则须要显式地提供了。下面是发送不记名token的格式:
1 (venv) $ http GET http://localhost:5000/api/users/1 \ 2 "Authorization:Bearer pC1Nu9wwyNt8VCj1trWilFdFI276AcbS"
我将要实现的最后一个token相关功能是token撤销,以下所示:
app/api/tokens.py:撤销token。
1 from app.api.auth import token_auth 2 3 @bp.route('/tokens', methods=['DELETE']) 4 @token_auth.login_required 5 def revoke_token(): 6 g.current_user.revoke_token() 7 db.session.commit() 8 return '', 204
客户端能够向/tokens URL发送DELETE
请求,以使token失效。此路由的身份验证是基于token的,事实上,在Authorization
头部中发送的token就是须要被撤销的。撤销使用了User
类中的辅助方法,该方法从新设置token过时日期来实现撤销操做。以后提交数据库会话,以确保将更改写入数据库。这个请求的响应没有正文,因此我能够返回一个空字符串。Return语句中的第二个值设置状态代码为204,该代码用于成功请求却没有响应主体的响应。
下面是撤销token的一个HTTPie请求示例:
1 (venv) $ http DELETE http://localhost:5000/api/tokens \ 2 Authorization:"Bearer pC1Nu9wwyNt8VCj1trWilFdFI276AcbS"
你是否还记得,在本章的前部分,当我要求你用一个无效的用户URL从浏览器发送一个API请求时发生了什么?服务器返回了404错误,可是这个错误被格式化为标准的404 HTML错误页面。在API blueprint中的API可能返回的许多错误能够被重写为JSON版本,可是仍然有一些错误是由Flask处理的,处理这些错误的处理函数是被全局注册到应用中的,返回的是HTML。
HTTP协议支持一种机制,经过该机制,客户机和服务器能够就响应的最佳格式达成一致,称为内容协商。客户端须要发送一个Accept
头部,指示格式首选项。而后,服务器查看自身格式列表并使用匹配客户端格式列表中的最佳格式进行响应。
我想作的是修改全局应用的错误处理器,使它们可以根据客户端的格式首选项对返回内容是使用HTML仍是JSON进行内容协商。这能够经过使用Flask的request.accept_mimetypes
来完成:
app/errors/handlers.py:为错误响应进行内容协商。
1 from flask import render_template, request 2 from app import db 3 from app.errors import bp 4 from app.api.errors import error_response as api_error_response 5 6 def wants_json_response(): 7 return request.accept_mimetypes['application/json'] >= \ 8 request.accept_mimetypes['text/html'] 9 10 @bp.app_errorhandler(404) 11 def not_found_error(error): 12 if wants_json_response(): 13 return api_error_response(404) 14 return render_template('errors/404.html'), 404 15 16 @bp.app_errorhandler(500) 17 def internal_error(error): 18 db.session.rollback() 19 if wants_json_response(): 20 return api_error_response(500) 21 return render_template('errors/500.html'), 500
wants_json_response()
辅助函数比较客户端对JSON和HTML格式的偏好程度。 若是JSON比HTML高,那么我会返回一个JSON响应。 不然,我会返回原始的基于模板的HTML响应。 对于JSON响应,我将使用从API blueprint中导入error_response
辅助函数,但在这里我要将其重命名为api_error_response()
,以便清楚它的做用和来历。
http://localhost:5000/api/users/1