Python-REST-Web-服务构建指南-全-
Python REST Web 服务构建指南(全)
原文:
zh.annas-archive.org/md5/02eadb30335bca313871c8d62bd41dad译者:飞龙
前言
REST(表征状态转移)是推动现代 Web 开发和移动应用架构风格。实际上,开发和使用 RESTful Web 服务是任何现代软件开发工作必备的技能。有时,你必须与现有的 API 交互,在其他情况下,你必须从头设计一个 RESTful API 并使其与JSON(JavaScript 对象表示法)兼容。
Python 是最受欢迎的编程语言之一。Python 3.5 是 Python 最现代的版本。它是开源的、多平台的,你可以用它来开发任何类型的应用程序,从网站到极其复杂的科学计算应用程序。总有一个 Python 包可以使事情变得更容易,避免重复造轮子并更快地解决问题。最重要的和最受欢迎的云计算提供商使得使用 Python 及其相关的 Web 框架变得容易。因此,Python 是开发 RESTful Web 服务的理想选择。本书涵盖了你需要知道的所有内容,以选择最合适的 Python Web 框架并从头开始开发 RESTful API。
你将使用三个最受欢迎的 Python Web 框架,这些框架使得开发 RESTful Web 服务变得简单:Django、Flask 和 Tornado。每个 Web 框架都有其优势和权衡。你将使用代表这些 Web 框架适当案例的示例,结合额外的 Python 包来简化最常见的任务。你将学习使用不同的工具来测试和开发高质量、一致且可扩展的 RESTful Web 服务。你还将利用面向对象编程(也称为 OOP)来最大化代码重用并最小化维护成本。
你将始终为书中开发的每个 RESTful Web 服务编写单元测试并提高测试覆盖率。你不仅会运行示例代码,还会确保为你的 RESTful API 编写测试。
这本书将帮助你学习如何利用许多简化与 RESTful Web 服务相关常见任务的软件包。你将能够开始为任何领域创建自己的 RESTful API,这些 API 可以在 Python 3.5 或更高版本的任何覆盖的 Web 框架中实现。
本书涵盖内容
第一章,使用 Django 开发 RESTful API,在本章中,我们将开始使用 Django 和 Django REST 框架,并创建一个对简单 SQLite 数据库执行CRUD(创建、读取、更新和删除)操作的 RESTful Web API。
第二章, 在 Django 中使用基于类的视图和超链接 API,在本章中,我们将扩展上一章中开始构建的 RESTful API 的功能。我们将更改 ORM 设置以使用更强大的 PostgreSQL 数据库,并利用 Django REST Framework 中包含的先进功能,这些功能允许我们减少复杂 API(如基于类的视图)的样板代码。
第三章, 在 Django API 中改进和添加认证,在本章中,我们将改进上一章中开始构建的 RESTful API。我们将向模型添加唯一约束并更新数据库。我们将通过 PATCH 方法简化单个字段的更新,并利用分页功能。我们将开始处理认证、权限和限制。
第四章, 使用 Django REST Framework 限制、过滤、测试和部署 API,在本章中,我们将利用 Django REST Framework 中包含的许多功能来定义限制策略。我们将使用过滤、搜索和排序类来简化配置过滤器、搜索查询和结果排序的 HTTP 请求。我们将使用可浏览的 API 功能来测试我们 API 中包含的新特性。我们将编写第一轮单元测试,测量测试覆盖率,然后编写额外的单元测试以提高测试覆盖率。最后,我们将学习许多关于部署和可扩展性的考虑因素。
第五章 , 使用 Flask 开发 RESTful API,在本章中,我们将开始使用 Flask 及其 Flask-RESTful 扩展。我们将创建一个执行简单列表 CRUD 操作的 RESTful Web API。
第六章, 在 Flask 中使用模型、SQLAlchemy 和超链接 API,在本章中,我们将扩展上一章中开始构建的 RESTful API 的功能。我们将使用 SQLAlchemy 作为我们的 ORM 来与 PostgreSQL 数据库交互,并利用 Flask 和 Flask-RESTful 中包含的先进功能,这些功能将使我们能够轻松组织复杂 API(如模型和蓝图)的代码。
第七章, 使用 Flask 改进和添加认证到 API,在本章中,我们将从多个方面改进 RESTful API。当资源不唯一时,我们将添加用户友好的错误消息。我们将测试如何使用 PATCH 方法更新单个或多个字段,并创建我们自己的通用分页类。然后,我们将开始处理认证和权限。我们将添加用户模型并更新数据库。我们将对代码的不同部分进行许多更改以实现特定的安全目标,并利用 Flask-HTTPAuth 和 passlib 在我们的 API 中使用 HTTP 认证。
第八章, 使用 Flask 测试和部署 API,在本章中,我们将设置测试环境。我们将安装 nose2 以简化单元测试的发现和执行,并创建一个新的数据库用于测试。我们将编写第一轮单元测试,测量测试覆盖率,然后编写额外的单元测试以提高测试覆盖率。最后,我们将学习许多关于部署和可扩展性的考虑因素。
第九章,使用 Tornado 开发 RESTful API,我们将与 Tornado 一起创建一个 RESTful Web API。我们将设计一个 RESTful API 来与慢速传感器和执行器交互。我们将定义 API 的需求,并理解每个 HTTP 方法执行的任务。我们将创建代表无人机的类,并编写代码来模拟每个 HTTP 请求方法所需的慢速 I/O 操作。我们将编写代表请求处理器的类,处理不同的 HTTP 请求,并配置 URL 模式将 URL 路由到请求处理器及其方法。
第十章, 使用 Tornado 处理异步代码、测试和部署 API,在本章中,我们将了解同步执行和异步执行之间的区别。我们将创建一个利用 Tornado 的非阻塞特性结合异步执行的 RESTful API 新版本。我们将提高现有 API 的可扩展性,并使其在等待传感器和执行器的慢速 I/O 操作时能够启动执行其他请求。然后,我们将设置测试环境。我们将安装 nose2 以简化单元测试的发现和执行,并创建一个新的数据库用于测试。我们将编写第一轮单元测试,测量测试覆盖率,然后编写额外的单元测试以提高测试覆盖率。我们将创建所有必要的测试,以确保对所有代码行的全面覆盖。
你需要这本书的
为了使用 Python 3.5.x 的不同样本,您需要任何配备 Intel Core i3 或更高 CPU 以及至少 4 GB RAM 的计算机。您可以使用以下任何一种操作系统:
-
Windows 7 或更高版本(Windows 8、Windows 8.1 或 Windows 10)
-
macOS Mountain Lion 或更高版本
-
任何能够运行 Python 3.5.x 的 Linux 版本以及任何支持 JavaScript 的现代浏览器
您需要在计算机上安装 Python 3.5 或更高版本。
本书面向对象
这本书是为那些对 Python 有一定了解并希望通过利用 Python 的各种框架来构建令人惊叹的 Web 服务而编写的 Web 开发者。您应该对 RESTful API 有一些了解。
惯例
在本书中,您将找到许多文本样式,用于区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。
文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 账号将以以下方式显示:“如果没有游戏匹配指定的 id 或主键,服务器将返回 404 Not Found 状态。”
代码块将如下设置:
from django.apps import AppConfig
class GamesConfig(AppConfig):
name = 'games'
任何命令行输入或输出将如下所示:
python3 -m venv ~/PythonREST/Django01
注意
警告或重要注意事项将以这样的框显示。
小贴士
小贴士和技巧将以这样的形式显示。
读者反馈
我们欢迎读者的反馈。请告诉我们您对这本书的看法——您喜欢或不喜欢什么。读者反馈对我们来说很重要,因为它帮助我们开发出您真正能从中获得最大收益的标题。要发送一般反馈,请简单地发送电子邮件至 feedback@packtpub.com,并在邮件主题中提及书籍的标题。如果您在某个主题领域有专业知识,并且对撰写或为书籍做出贡献感兴趣,请参阅我们的作者指南www.packtpub.com/authors。
客户支持
现在,您已经是 Packt 书籍的骄傲拥有者,我们有一些事情可以帮助您从您的购买中获得最大收益。
下载示例代码
您可以从您的 www.packtpub.com 账户下载本书的示例代码文件。如果您在其他地方购买了这本书,您可以访问 www.packtpub.com/support 并注册,以便将文件直接通过电子邮件发送给您。
您可以通过以下步骤下载代码文件:
-
使用您的电子邮件地址和密码登录或注册我们的网站。
-
将鼠标指针悬停在顶部的 支持 选项卡上。
-
点击 代码下载与勘误。
-
在 搜索 框中输入书籍名称。
-
选择您想要下载代码文件的书籍。
-
从下拉菜单中选择您购买此书的来源。
-
点击 代码下载。
文件下载后,请确保使用最新版本的以下软件解压缩或提取文件夹:
-
Windows 的 WinRAR / 7-Zip
-
Zipeg / iZip / UnRarX for Mac
-
7-Zip / PeaZip for Linux
该书的代码包也托管在 GitHub 上,网址为 github.com/PacktPublishing/Building-RESTful-Python-Web-Services。我们还有其他来自我们丰富图书和视频目录的代码包,可在 github.com/PacktPublishing/ 找到。查看它们吧!
勘误
尽管我们已经尽一切努力确保我们内容的准确性,但错误仍然会发生。如果您在我们的某本书中发现错误——可能是文本或代码中的错误——如果您能向我们报告这一点,我们将不胜感激。通过这样做,您可以节省其他读者的挫败感,并帮助我们改进本书的后续版本。如果您发现任何勘误,请通过访问 www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入您的勘误详情来报告它们。一旦您的勘误得到验证,您的提交将被接受,勘误将被上传到我们的网站或添加到该标题的勘误部分下的现有勘误列表中。
要查看之前提交的勘误表,请访问 www.packtpub.com/books/content/support,并在搜索字段中输入书籍名称。所需信息将在勘误部分显示。
盗版
互联网上版权材料的盗版是一个持续存在的问题,涉及所有媒体。在 Packt,我们非常重视我们版权和许可证的保护。如果您在互联网上发现我们作品的任何非法副本,请立即提供位置地址或网站名称,以便我们可以寻求补救措施。
请通过 copyright@packtpub.com 联系我们,并提供涉嫌盗版材料的链接。
我们感谢您在保护我们作者和我们为您提供有价值内容的能力方面提供的帮助。
问题
如果您对本书的任何方面有问题,您可以通过 questions@packtpub.com 联系我们,我们将尽力解决问题。
第一章:使用 Django 开发 RESTful API
在本章中,我们将使用 Python 和四个不同的 Web 框架开始我们的 RESTful Web API 之旅。Python 是最受欢迎和最灵活的编程语言之一。有成千上万的 Python 包,允许你扩展 Python 的能力到任何你能想象的领域。我们可以使用许多不同的 Web 框架和包,轻松地用 Python 构建 simple 和 complex 的 RESTful Web API,我们还可以将这些框架与其他 Python 包结合使用。
我们可以利用我们对 Python 及其包的现有知识来编写我们 RESTful Web API 的不同部分及其生态系统。我们可以使用面向对象的功能来创建易于维护、理解和重用的代码。我们可以使用我们已知的所有包来与数据库、Web 服务和不同的 API 交互。Python 使我们能够轻松创建 RESTful Web API。我们不需要学习另一种编程语言;我们可以使用我们已知的并喜爱的语言。
在本章中,我们将开始使用 Django 和 Django REST 框架,并创建一个 RESTful Web API,该 API 在一个简单的 SQLite 数据库上执行 CRUD(创建、读取、更新和删除)操作。我们将:
-
设计一个与简单 SQLite 数据库交互的 RESTful API
-
理解每个 HTTP 方法执行的任务
-
使用 Django REST 框架设置虚拟环境
-
创建数据库模型
-
管理数据的序列化和反序列化
-
编写 API 视图
-
使用命令行工具向 API 发送 HTTP 请求
-
使用 GUI 工具来组合和发送 HTTP 请求
设计一个与简单 SQLite 数据库交互的 RESTful API
假设我们必须开始开发一个需要与 RESTful API 交互以执行 CRUD 操作的游戏移动应用。我们不希望花费时间选择和配置最合适的 ORM(对象关系映射);我们只想尽快完成 RESTful API,以便通过我们的移动应用与之交互。我们确实希望游戏持久保存在数据库中,但我们不需要它具备生产就绪状态,因此,我们可以使用最简单的可能的关系数据库,只要我们不需要花费时间进行复杂的安装或配置。
Django REST 框架,也称为 DRF,将使我们能够轻松地完成这项任务,并开始向我们的第一个 RESTful Web 服务发送 HTTP 请求。在这种情况下,我们将使用一个非常简单的 SQLite 数据库,它是新 Django REST 框架项目的默认数据库。
首先,我们必须指定我们主要资源:游戏的要求。对于一个游戏,我们需要以下属性或字段:
-
一个整数标识符
-
一个名称或标题
-
一个发布日期
-
一个游戏类别描述,例如 3D RPG 和 2D 移动街机。
-
一个
bool值,表示玩家是否至少玩过一次游戏
此外,我们希望我们的数据库保存一个时间戳,记录游戏被插入数据库的日期和时间。
下表显示了我们的 API 第一版必须支持的 HTTP 动词、作用域和方法语义。每个方法由一个 HTTP 动词和一个作用域组成,并且所有方法对所有游戏和集合都有一个明确定义的意义。
| HTTP 动词 | 作用域 | 语义 |
|---|---|---|
GET |
游戏集合 | 获取集合中存储的所有游戏,按名称升序排序 |
GET |
游戏 | 获取单个游戏 |
POST |
游戏集合 | 在集合中创建新游戏 |
PUT |
游戏 | 更新现有游戏 |
DELETE |
游戏 | 删除现有游戏 |
小贴士
在 RESTful API 中,每个资源都有自己的唯一 URL。在我们的 API 中,每个游戏都有自己的唯一 URL。
理解每个 HTTP 方法执行的任务
在前一个表中,GET HTTP 动词出现了两次,但作用域不同。第一行显示了一个应用于游戏集合(资源集合)的 GET HTTP 动词,而第二行显示了一个应用于单个游戏(单一资源)的 GET HTTP 动词。
让我们考虑 http://localhost:8000/games/ 是游戏集合的 URL。如果我们向该 URL 添加一个数字和一个斜杠(/),我们就可以识别一个特定的游戏,其 id 或主键等于指定的数值。例如,http://localhost:8000/games/12/ 识别 id 或主键等于 12 的游戏。
我们必须使用以下 HTTP 动词(POST)和请求 URL(http://localhost:8000/games/)来创建一个新的游戏。此外,我们必须提供 JSON(JavaScript 对象表示法)键值对,包括字段名称和值以创建新游戏。作为请求的结果,服务器将验证提供的字段值,确保它是一个有效的游戏并将其持久化到数据库中。
服务器将在适当的表中插入一行新游戏,并返回一个 201 已创建 状态码,以及一个包含最近添加的游戏序列化为 JSON 的 JSON 主体,包括由数据库自动生成并分配给游戏对象的分配 id 或主键。
POST http://localhost:8000/games/
我们必须使用以下 HTTP 动词(GET)和请求 URL(http://localhost:8000/games/{id}/)来检索 id 或主键与 {id} 处指定的数值匹配的游戏。
例如,如果我们使用请求 URL http://localhost:8000/games/50/,服务器将检索 id 或主键匹配 50 的游戏。
作为请求的结果,服务器将从数据库中检索具有指定 ID 或主键的游戏,并在 Python 中创建相应的游戏对象。如果找到游戏,服务器将游戏对象序列化为 JSON,并返回200 OK状态码和一个包含序列化游戏对象的 JSON 体。如果没有找到与指定 ID 或主键匹配的游戏,服务器将仅返回404 Not Found状态:
GET http://localhost:8000/games/{id}/
我们必须使用以下 HTTP 动词(PUT)和请求 URL(http://localhost:8000/games/{id}/)来发送一个 HTTP 请求,以检索在{id}位置指定的数值匹配的游戏 ID 或主键,并用提供的数据创建的游戏替换它。此外,我们必须提供带有字段名称和值的 JSON 键值对,以创建将替换现有游戏的新游戏。作为请求的结果,服务器将验证提供的字段值,确保它是一个有效的游戏,并在数据库中将与指定 ID 或主键匹配的现有游戏替换为新游戏。更新操作后,游戏的 ID 或主键将保持不变。服务器将在适当的表中更新现有行,并返回一个200 OK状态码和一个包含序列化到 JSON 的最近更新的游戏的 JSON 体。如果我们没有提供新游戏所需的所有必要数据,服务器将返回400 Bad Request状态码。如果服务器找不到指定 ID 的游戏,服务器将仅返回404 Not Found状态。
PUT http://localhost:8000/games/{id}/
我们必须使用以下 HTTP 动词(DELETE)和请求 URL(http://localhost:8000/games/{id}/)来发送一个 HTTP 请求,以删除 ID 或主键与在{id}位置指定的数值相匹配的游戏。例如,如果我们使用请求 URL http://localhost:8000/games/20/,服务器将删除 ID 或主键与20相匹配的游戏。作为请求的结果,服务器将从数据库中检索具有指定 ID 或主键的游戏,并在 Python 中创建相应的游戏对象。如果找到游戏,服务器将请求 ORM 删除与该游戏对象关联的游戏行,并返回204 No Content状态码。如果没有找到与指定 ID 或主键匹配的游戏,服务器将仅返回404 Not Found状态。
DELETE http://localhost:8000/games/{id}/
使用轻量级虚拟环境
在整本书中,我们将使用不同的框架和库,因此,使用虚拟环境是很方便的。我们将使用 Python 3.3 中引入并 Python 3.4 中改进的轻量级虚拟环境。然而,你也可以选择使用流行的virtualenv (pypi.python.org/pypi/virtualenv) 第三方虚拟环境构建器或你的 Python IDE 提供的虚拟环境选项。
你只需确保在需要时使用适当的机制激活你的虚拟环境,而不是遵循使用 Python 中集成的venv模块生成的虚拟环境的步骤。你可以在www.python.org/dev/peps/pep-0405上阅读更多关于引入venv模块的 PEP 405 Python 虚拟环境的信息。
小贴士
我们使用venv创建的每个虚拟环境都是一个隔离的环境,并且它将在其 site 目录中拥有自己独立的一组已安装的 Python 包。当我们使用 Python 3.4 及更高版本中的venv创建虚拟环境时,pip 将包含在新的虚拟环境中。在 Python 3.3 中,创建虚拟环境后需要手动安装 pip。请注意,提供的说明与 Python 3.4 或更高版本兼容,包括 Python 3.5.x。以下命令假设你在 macOS、Linux 或 Windows 上安装了 Python 3.5.x。
首先,我们必须选择我们的虚拟环境的目标文件夹或目录。以下是在示例中我们将使用的路径,用于 macOS 和 Linux。虚拟环境的目标文件夹将是我们主目录中的PythonREST/Django文件夹。例如,如果我们的 macOS 或 Linux 中的主目录是/Users/gaston,虚拟环境将在/Users/gaston/PythonREST/Django中创建。你可以在每个命令中将指定的路径替换为你想要的路径。
~/PythonREST/Django
以下是在示例中我们将使用的路径。虚拟环境的目标文件夹将是我们用户配置文件中的PythonREST/Django文件夹。例如,如果我们的用户配置文件是C:\Users\Gaston,虚拟环境将在C:\Users\gaston\PythonREST\Django中创建。你可以在每个命令中将指定的路径替换为你想要的路径。
%USERPROFILE%\PythonREST\Django
现在,我们必须使用-m选项后跟venv模块名称和所需的路径,以便 Python 将此模块作为脚本运行并创建指定路径中的虚拟环境。根据我们创建虚拟环境的平台,说明可能会有所不同。
在 macOS 或 Linux 中打开一个终端并执行以下命令以创建虚拟环境:
python3 -m venv ~/PythonREST/Django01
在 Windows 中,执行以下命令以创建虚拟环境:
python -m venv %USERPROFILE%\PythonREST\Django01
前一个命令不会产生任何输出。脚本创建了指定的目标文件夹,并通过调用ensurepip安装了 pip,因为我们没有指定--without-pip选项。指定的目标文件夹包含一个新的目录树,其中包含 Python 可执行文件和其他表明它是一个虚拟环境的文件。
pyenv.cfg配置文件指定了虚拟环境的不同选项,其存在表明我们处于虚拟环境的根目录。在 OS 和 Linux 中,该文件夹将包含以下主要子文件夹—bin、include、lib、lib/python3.5和lib/python3.5/site-packages。在 Windows 中,该文件夹将包含以下主要子文件夹—Include、Lib、Lib\site-packages和Scripts。每个平台中虚拟环境的目录树与这些平台中 Python 安装的布局相同。以下截图显示了在 macOS 中为Django01虚拟环境生成的目录树中的文件夹和文件:

以下截图显示了为 Windows 中的虚拟环境生成的目录树中的主要文件夹:

小贴士
激活虚拟环境后,我们将安装第三方软件包到虚拟环境中,模块将位于lib/python3.5/site-packages或Lib\site-packages文件夹中,具体取决于平台。可执行文件将被复制到bin或Scripts文件夹中,具体取决于平台。我们安装的软件包不会更改其他虚拟环境或我们的基础 Python 环境。
现在我们已经创建了一个虚拟环境,我们将运行一个特定平台的脚本以激活它。激活虚拟环境后,我们将安装仅在此虚拟环境中可用的软件包。
在 macOS 或 Linux 的终端中运行以下命令。请注意,如果您在终端会话中没有启动除默认 shell 之外的其他 shell,则此命令的结果将准确无误。如果您有疑问,请检查您的终端配置和首选项。
echo $SHELL
该命令将显示您在终端中使用的 shell 名称。在 macOS 中,默认为/bin/bash,这意味着您正在使用 bash shell。根据 shell 的不同,您必须在 OS 或 Linux 中运行不同的命令来激活虚拟环境。
如果您的终端配置为在 macOS 或 Linux 中使用bash shell,请运行以下命令以激活虚拟环境。该命令也适用于zsh shell:
source ~/PythonREST/Django01/bin/activate
如果您的终端配置为使用csh或tcsh shell,请运行以下命令以激活虚拟环境:
source ~/PythonREST/Django01/bin/activate.csh
如果您的终端配置为使用fish shell,请运行以下命令以激活虚拟环境:
source ~/PythonREST/Django01/bin/activate.fish
在 Windows 中,您可以在命令提示符中运行批处理文件或在 Windows PowerShell 中运行脚本以激活虚拟环境。如果您更喜欢命令提示符,请在 Windows 命令行中运行以下命令以激活虚拟环境:
%USERPROFILE%\PythonREST\Django01\Scripts\activate.bat
如果你更喜欢 Windows PowerShell,请启动它并运行以下命令来激活虚拟环境。然而,请注意,你需要在 Windows PowerShell 中启用脚本执行才能运行脚本:
cd $env:USERPROFILE
PythonREST\Django01\Scripts\Activate.ps1
在激活虚拟环境后,命令提示符将显示括号内的虚拟环境根文件夹名称作为默认提示的前缀,以提醒我们我们正在虚拟环境中工作。在这种情况下,我们将看到 (Django01) 作为命令提示符的前缀,因为激活的虚拟环境的根文件夹是 Django01。
以下截图显示了在 macOS El Capitan 终端中执行之前显示的命令后激活的虚拟环境:

正如我们在前面的截图中所见,在激活虚拟环境后,提示符从 Gastons-MacBook-Pro:~ gaston$ 变成了 (Django01) Gastons-MacBook-Pro:~ gaston$。
以下截图显示了在执行之前显示的命令后,在 Windows 10 命令提示符中激活的虚拟环境:

正如我们从前面的截图中所注意到的,在激活虚拟环境后,提示符从 C:\Users\gaston\AppData\Local\Programs\Python\Python35 变成了 (Django01) C:\Users\gaston\AppData\Local\Programs\Python\Python35。
小贴士
使用之前解释的过程生成的虚拟环境非常容易取消激活。在 macOS 或 Linux 中,只需键入 deactivate 并按 Enter 键。在 Windows 命令提示符中,你必须运行包含在 Scripts 文件夹中的 deactivate.bat 批处理文件(在我们的例子中是 %USERPROFILE%\PythonREST\Django01\Scripts\deactivate.bat)。在 Windows PowerShell 中,你必须运行 Scripts 文件夹中的 Deactivate.ps1 脚本。取消激活将删除在环境变量中做出的所有更改。
设置 Django REST 框架的虚拟环境
我们已经创建并激活了一个虚拟环境。现在是时候运行许多在 macOS、Linux 或 Windows 上都相同的命令了。现在,我们必须运行以下命令来安装 Django Web 框架:
pip install django
输出的最后几行将指示 django 包已成功安装。请注意,你也可能看到升级 pip 的通知。
Collecting django
Installing collected packages: django
Successfully installed django-1.10
现在我们已经安装了 Django Web 框架,我们可以安装 Django REST 框架。我们只需运行以下命令来安装此包:
pip install djangorestframework
输出的最后几行将指示 djangorestframework 包已成功安装:
Collecting djangorestframework
Installing collected packages: djangorestframework
Successfully installed djangorestframework-3.3.3
进入虚拟环境的根文件夹 Django01。在 macOS 或 Linux 中,输入以下命令:
cd ~/PythonREST/Django01
在 Windows 中,输入以下命令:
cd /d %USERPROFILE%\PythonREST\Django01
执行以下命令以创建一个名为 gamesapi 的新 Django 项目。该命令不会产生任何输出:
django-admin.py startproject gamesapi
之前的命令创建了一个包含其他子文件夹和 Python 文件的 gamesapi 文件夹。现在,前往最近创建的 gamesapi 文件夹。只需执行以下命令:
cd gamesapi
然后,运行以下命令以在 gamesapi Django 项目中创建一个名为 games 的新 Django 应用。该命令不会产生任何输出:
python manage.py startapp games
之前的命令创建了一个新的 gamesapi/games 子文件夹,包含以下文件:
-
__init__.py -
admin.py -
apps.py -
models.py -
tests.py -
views.py
此外,gamesapi/games 文件夹将有一个 migrations 子文件夹,其中包含一个 __init__.py Python 脚本。以下图显示了以 gamesapi 文件夹为起点的目录树中的文件夹和文件:

让我们检查 gamesapi/games 文件夹内 apps.py 文件中的 Python 代码。以下行显示了该文件的代码:
from django.apps import AppConfig
class GamesConfig(AppConfig):
name = 'games'
代码声明 GamesConfig 类作为 django.apps.AppConfig 类的子类,该类代表 Django 应用及其配置。GamesConfig 类仅定义了 name 类属性,并将其值设置为 'games'。我们必须将 games.apps.GamesConfig 添加到配置 gamesapi Django 项目的 gamesapi/settings.py 文件中的已安装应用之一。我们构建前面的字符串如下 - 应用名称 + .apps. + 类名称,即 games + .apps. + GamesConfig。此外,我们还需要添加 rest_framework 应用,以便我们能够使用 Django REST 框架。
gamesapi/settings.py 文件是一个 Python 模块,其中包含模块级别的变量,用于定义 gamesapi 项目的 Django 配置。我们将对此 Django 设置文件进行一些修改。打开 gamesapi/settings.py 文件,找到以下指定已安装应用字符串列表的行:
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
]
将以下两个字符串添加到 INSTALLED_APPS 字符串列表中,并将更改保存到 gamesapi/settings.py 文件中:
-
'rest_framework' -
'games.apps.GamesConfig'
以下行显示了带有突出显示的新代码,该代码声明了带有添加行的 INSTALLED_APPS 字符串列表。示例代码文件包含在 restful_python_chapter_01_01 文件夹中:
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
# Django REST Framework
'rest_framework',
# Games application
'games.apps.GamesConfig',
]
这样,我们就将 Django REST 框架和 games 应用添加到了我们的初始 Django 项目 gamesapi 中。
创建模型
现在,我们将创建一个简单的 Game 模型,我们将使用它来表示和持久化游戏。打开 games/models.py 文件。以下行显示了该文件的初始代码,仅包含一个导入语句和一个注释,指示我们应该创建模型:
from django.db import models
# Create your models here.
以下行显示了在 games/models.py 文件中创建 Game 类的新代码,特别是创建 Game 模型。示例的代码文件包含在 restful_python_chapter_01_01 文件夹中:
from django.db import models
class Game(models.Model):
created = models.DateTimeField(auto_now_add=True)
name = models.CharField(max_length=200, blank=True, default='')
release_date = models.DateTimeField()
game_category = models.CharField(max_length=200, blank=True, default='')
played = models.BooleanField(default=False)
class Meta:
ordering = ('name',)
Game 类是 django.db.models.Model 类的子类。每个定义的属性代表一个数据库列或字段。当 Django 创建与模型相关的数据库表时,它会自动添加一个名为 id 的自增整数主键列。然而,模型将底层的 id 列映射到名为 pk 的属性上。我们指定了许多属性的字段类型、最大长度和默认值。该类声明了一个名为 Meta 的内部类,该类声明了一个排序属性并将其值设置为字符串的元组,其中第一个值是 'name' 字符串,表示我们默认希望按 name 属性的升序排序结果。
然后,我们需要为新近编写的 Game 模型创建初始迁移。我们只需运行以下 Python 脚本,我们还将首次同步数据库。默认情况下,Django 使用 SQLite 数据库。在这个例子中,我们将使用这个默认配置:
python manage.py makemigrations games
以下行显示了运行上述命令后生成的输出。
Migrations for 'games':
0001_initial.py:
- Create model Game
输出表明 gamesapi/games/migrations/0001_initial.py 文件包含了创建 Game 模型的代码。以下行显示了由 Django 自动生成的此文件的代码。示例的代码文件包含在 restful_python_chapter_01_01 文件夹中:
# -*- coding: utf-8 -*-
# Generated by Django 1.9.6 on 2016-05-17 21:19
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Game',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True,
serialize=False, verbose_name='ID')),
('created', models.DateTimeField(auto_now_add=True)),
('name', models.CharField(blank=True, default='',
max_length=200)),
('release_date', models.DateTimeField()),
('game_category', models.CharField(blank=True, default='',
max_length=200)),
('played', models.BooleanField(default=False)),
],
options={
'ordering': ('name',),
},
),
]
代码定义了一个名为 Migration 的 django.db.migrations.Migration 类的子类,该类定义了一个创建 Game 模型表的操作。现在,运行以下 Python 脚本来应用所有生成的迁移:
python manage.py migrate
以下行显示了运行上述命令后生成的输出:
Operations to perform:
Apply all migrations: sessions, games, contenttypes, admin, auth
Running migrations:
Rendering model states... DONE
Applying contenttypes.0001_initial... OK
Applying auth.0001_initial... OK
Applying admin.0001_initial... OK
Applying admin.0002_logentry_remove_auto_add... OK
Applying contenttypes.0002_remove_content_type_name... OK
Applying auth.0002_alter_permission_name_max_length... OK
Applying auth.0003_alter_user_email_max_length... OK
Applying auth.0004_alter_user_username_opts... OK
Applying auth.0005_alter_user_last_login_null... OK
Applying auth.0006_require_contenttypes_0002... OK
Applying auth.0007_alter_validators_add_error_messages... OK
Applying games.0001_initial... OK
Applying sessions.0001_initial... OK
在运行上述命令后,我们会注意到我们的 gamesapi 项目根目录现在有一个 db.sqlite3 文件。我们可以使用 SQLite 命令行或任何其他允许我们轻松检查 SQLite 数据库内容的程序来检查 Django 生成的表。
在 macOS 和大多数现代 Linux 发行版中,SQLite 已经安装,因此你可以运行 sqlite3 命令行工具。然而,在 Windows 上,如果你想要使用 sqlite3.exe 命令行工具,你必须从其网页下载并安装 SQLite - www.sqlite.org。
运行以下命令以列出生成的表:
sqlite3 db.sqlite3 '.tables'
运行以下命令以检索创建 games_game 表所用的 SQL:
sqlite3 db.sqlite3 '.schema games_game'
以下命令允许你在向 RESTful API 发送 HTTP 请求并执行对 games_game 表的 CRUD 操作后,检查 games_game 表的内容:
sqlite3 db.sqlite3 'SELECT * FROM games_game ORDER BY name;'
你可以选择使用图形界面工具来检查 SQLite 数据库的内容,而不是使用 SQLite 命令行工具。DB Browser for SQLite 是一个多平台且免费的图形界面工具,它允许我们在 macOS、Linux 和 Windows 上轻松检查 SQLite 数据库的内容。你可以从sqlitebrowser.org了解更多关于这个工具的信息,并下载其不同版本。一旦安装了该工具,你只需打开db.sqlite3文件,就可以检查数据库结构并浏览不同表的数据。你也可以使用你喜欢的 IDE 中包含的数据库工具来检查 SQLite 数据库的内容。
SQLite 数据库引擎和数据库文件名在gamesapi/settings.pyPython 文件中指定。以下行显示了包含 Django 使用的所有数据库设置的DATABASES字典的声明。嵌套字典将名为default的数据库映射到django.db.backends.sqlite3数据库引擎和位于BASE_DIR文件夹(gamesapi)中的db.sqlite3数据库文件:
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': os.path.join(BASE_DIR, 'db.sqlite3'),
}
}
执行迁移后,SQLite 数据库将包含以下表:
-
auth_group -
auth_group_permissions -
auth_permission -
auth_user -
auth_user_groups -
auth_user_groups_permissions -
django_admin_log -
django_content_type -
django_migrations -
django_session -
games_game -
sqlite_sequence
games_game表在数据库中持久化了我们最近创建的Game类,具体来说是Game模型。Django 的集成 ORM 根据我们的Game模型生成了games_game表。games_game表有以下行(也称为字段),以及它们的 SQLite 类型,所有这些字段都不是可空的:
-
id: 整数主键,一个autoincrement行 -
created:datetime -
name:varchar(200) -
release_date:datetime -
game_category:varchar(200) -
played:bool
以下行显示了 Django 在执行迁移时生成的 SQL 创建脚本:
CREATE TABLE "games_game" (
"id" integer NOT NULL PRIMARY KEY AUTOINCREMENT,
"created" datetime NOT NULL,
"name" varchar(200) NOT NULL,
"release_date" datetime NOT NULL,
"game_category" varchar(200) NOT NULL,
"played" bool NOT NULL
)
Django 生成了额外的表,这些表是它支持 Web 框架和我们将要使用的认证功能所必需的。
管理序列化和反序列化
我们的 RESTful Web API 必须能够将游戏实例序列化为 JSON 表示,并从 JSON 反序列化。使用 Django REST Framework,我们只需为游戏实例创建一个序列化器类来管理序列化为 JSON 和从 JSON 反序列化。
Django REST 框架使用两阶段过程进行序列化。序列化器是模型实例和 Python 基本类型之间的中介。解析器和渲染器处理 Python 基本类型和 HTTP 请求及响应之间的中介。我们将通过创建 rest_framework.serializers.Serializer 类的子类来配置我们的中介,以声明字段和必要的序列化和反序列化管理方法。我们将重复一些关于字段的信息,这些信息我们已经包含在 Game 模型中,以便我们理解在序列化器类的子类中可以配置的所有内容。然而,我们将使用快捷方式,这将在下一个示例中减少样板代码。我们将通过使用 ModelSerializer 类来在下一个示例中编写更少的代码。
现在,转到 gamesapi/games 文件夹,并创建一个名为 serializers.py 的新 Python 代码文件。以下行显示了声明新 GameSerializer 类的代码。示例的代码文件包含在 restful_python_chapter_01_01 文件夹中。
from rest_framework import serializers
from games.models import Game
class GameSerializer(serializers.Serializer):
pk = serializers.IntegerField(read_only=True)
name = serializers.CharField(max_length=200)
release_date = serializers.DateTimeField()
game_category = serializers.CharField(max_length=200)
played = serializers.BooleanField(required=False)
def create(self, validated_data):
return Game.objects.create(**validated_data)
def update(self, instance, validated_data):
instance.name = validated_data.get('name', instance.name)
instance.release_date = validated_data.get('release_date', instance.release_date)
instance.game_category = validated_data.get('game_category', instance.game_category)
instance.played = validated_data.get('played', instance.played)
instance.save()
return instance
GameSerializer 类声明了代表我们想要序列化的字段的属性。注意,它们省略了在 Game 模型中存在的 created 属性。当对这个类的继承 save 方法进行调用时,重写的 create 和 update 方法定义了如何创建或修改实例。实际上,这些方法必须在我们的类中实现,因为它们在其基本声明中只是抛出一个 NotImplementedError 异常。
create 方法接收 validated_data 参数中的验证数据。代码根据接收到的验证数据创建并返回一个新的 Game 实例。
update 方法接收一个正在更新的现有 Game 实例和包含在 instance 和 validated_data 参数中的新验证数据。代码使用从验证数据中检索的更新属性值更新实例的属性值,调用更新 Game 实例的保存方法,并返回更新和保存的实例。
我们可以在启动之前启动默认的 Python 交互式 shell 并使所有 Django 项目模块可用。这样,我们可以检查序列化器是否按预期工作。此外,它将帮助我们理解 Django 中的序列化工作方式。运行以下命令以启动交互式 shell。确保你在终端或命令提示符中的 gamesapi 文件夹内:
python manage.py shell
你会注意到在通常介绍你的默认 Python 交互式 shell 的行之后,会显示一行说(InteractiveConsole)。在 Python 交互式 shell 中输入以下代码以导入我们将需要测试Game模型及其序列化器的所有内容。示例的代码文件包含在restful_python_chapter_01_01文件夹中的serializers_test_01.py文件里:
from datetime import datetime
from django.utils import timezone
from django.utils.six import BytesIO
from rest_framework.renderers import JSONRenderer
from rest_framework.parsers import JSONParser
from games.models import Game
from games.serializers import GameSerializer
输入以下代码以创建两个Game模型的实例并将它们保存。示例的代码文件包含在restful_python_chapter_01_01文件夹中的serializers_test_01.py文件里:
gamedatetime = timezone.make_aware(datetime.now(), timezone.get_current_timezone())
game1 = Game(name='Smurfs Jungle', release_date=gamedatetime, game_category='2D mobile arcade', played=False)
game1.save()
game2 = Game(name='Angry Birds RPG', release_date=gamedatetime, game_category='3D RPG', played=False)
game2.save()
执行完前面的代码后,我们可以使用之前介绍的命令行或 GUI 工具来检查 SQLite 数据库的内容,即games_game表的内容。我们会注意到该表有两行,列的值是我们提供给Game实例不同属性的值。
在交互式 shell 中输入以下命令以检查已保存的Game实例的主键或标识符的值以及created属性值,该值包括我们将实例保存到数据库中的日期和时间。示例的代码文件包含在restful_python_chapter_01_01文件夹中的serializers_test_01.py文件里:
print(game1.pk)
print(game1.name)
print(game1.created)
print(game2.pk)
print(game2.name)
print(game2.created)
现在,让我们编写以下代码来序列化第一个游戏实例(game1)。示例的代码文件包含在restful_python_chapter_01_01文件夹中的serializers_test_01.py文件里:
game_serializer1 = GameSerializer(game1)
print(game_serializer1.data)
以下行显示了生成的字典,具体来说,是一个rest_framework.utils.serializer_helpers.ReturnDict实例:
{'release_date': '2016-05-18T03:02:00.776594Z', 'game_category': '2D mobile arcade', 'played': False, 'pk': 2, 'name': 'Smurfs Jungle'}
现在,让我们序列化第二个游戏实例(game2)。示例的代码文件包含在restful_python_chapter_01_01文件夹中的serializers_test_01.py文件里:
game_serializer2 = GameSerializer(game2)
print(game_serializer2.data)
以下行显示了生成的字典:
{'release_date': '2016-05-18T03:02:00.776594Z', 'game_category': '3D RPG', 'played': False, 'pk': 3, 'name': 'Angry Birds RPG'}
我们可以使用rest_framework.renderers.JSONRenderer类轻松地将存储在data属性中的字典渲染成 JSON。以下行创建了这个类的实例,然后调用render方法将存储在data属性中的字典渲染成 JSON。示例的代码文件包含在restful_python_chapter_01_01文件夹中的serializers_test_01.py文件里:
renderer = JSONRenderer()
rendered_game1 = renderer.render(game_serializer1.data)
rendered_game2 = renderer.render(game_serializer2.data)
print(rendered_game1)
print(rendered_game2)
以下行显示了两次调用render方法生成的输出:
b'{"pk":2,"name":"Smurfs Jungle","release_date":"2016-05-
18T03:02:00.776594Z","game_category":"2D mobile arcade","played":false}'
b'{"pk":3,"name":"Angry Birds RPG","release_date":"2016-05-
18T03:02:00.776594Z","game_category":"3D RPG","played":false}'
现在,我们将反向操作:从序列化数据到填充一个Game实例。以下行从 JSON 字符串(序列化数据)生成一个新的Game实例,即它们将进行反序列化。示例的代码文件包含在restful_python_chapter_01_01文件夹中的serializers_test_01.py文件里:
json_string_for_new_game = '{"name":"Tomb Raider Extreme Edition","release_date":"2016-05-18T03:02:00.776594Z","game_category":"3D RPG","played":false}'
json_bytes_for_new_game = bytes(json_string_for_new_game , encoding="UTF-8")
stream_for_new_game = BytesIO(json_bytes_for_new_game)
parser = JSONParser()
parsed_new_game = parser.parse(stream_for_new_game)
print(parsed_new_game)
第一行创建了一个新的字符串,其中包含定义新游戏的 JSON(json_string_for_new_game)。然后,代码将字符串转换为 bytes 并将转换的结果保存在 json_bytes_for_new_game 变量中。django.utils.six.BytesIO 类提供了一个使用内存字节数组的缓冲 I/O 实现。代码使用这个类从之前生成的包含序列化数据的 JSON 字节(json_bytes_for_new_game)创建一个流,并将生成的实例保存在 stream_for_new_game 变量中。
我们可以使用 rest_framework.parsers.JSONParser 类轻松地将流反序列化和解析到 Python 模型中。下一行创建了这个类的实例,然后使用 stream_for_new_game 作为参数调用 parse 方法,将流解析为 Python 原生数据类型,并将结果保存在 parsed_new_game 变量中。
执行前面的行后,parsed_new_game 包含一个从流中解析的 Python 字典。以下行显示了执行前面的代码片段后的输出:
{'release_date': '2016-05-18T03:02:00.776594Z', 'played': False,
'game_category': '3D RPG', 'name': 'Tomb Raider Extreme Edition'}
以下行使用 GameSerializer 类从流中解析的 Python 字典生成一个完全填充的 Game 实例,名为 new_game。示例代码文件包含在 restful_python_chapter_01_01 文件夹中的 serializers_test_01.py 文件中。
new_game_serializer = GameSerializer(data=parsed_new_game)
if new_game_serializer.is_valid():
new_game = new_game_serializer.save()
print(new_game.name)
首先,代码创建了一个 GameSerializer 类的实例,该实例使用我们从流中之前解析的 Python 字典(parsed_new_game)作为 data 关键字参数传递。然后,代码调用 is_valid 方法以确定数据是否有效。请注意,我们必须始终在尝试访问序列化数据表示之前调用 is_valid,当我们传递 data 关键字参数创建序列化器时。
如果该方法返回 true,则我们可以访问 data 属性中的序列化表示,因此代码调用 save 方法将相应的行插入数据库,并返回一个完全填充的 Game 实例,保存在 new_game 本地变量中。然后,代码打印完全填充的 Game 实例的一个属性。在执行前面的代码后,我们完全填充了两个 Game 实例:new_game1_instance 和 new_game2_instance。
小贴士
如我们从前面的代码中可以学到的,Django REST 框架使得从对象序列化为 JSON 以及从 JSON 反序列化为对象变得容易,这是我们的必须执行 CRUD 操作的 RESTful Web API 的核心要求。
输入以下命令以退出包含我们开始测试序列化和反序列化的 Django 项目模块的 shell:
quit()
编写 API 视图
现在,我们将创建 Django 视图,这些视图将使用之前创建的GameSerializer类来为 API 处理的每个 HTTP 请求返回 JSON 表示。打开games/views.py文件。以下行显示了该文件的初始代码,只有一个导入语句和一个注释,表明我们应该创建视图。
from django.shortcuts import render
# Create your views here.
以下行显示了创建JSONResponse类并声明两个函数game_list和game_detail的新代码,这些函数位于games/views.py文件中。我们正在创建 API 的第一个版本,我们使用函数来尽可能简化代码。我们将在下一个示例中使用类和更复杂的代码。高亮行显示了评估request.method属性值的表达式,以确定基于 HTTP 动词要执行的操作。示例代码文件包含在restful_python_chapter_01_01文件夹中:
from django.http import HttpResponse
from django.views.decorators.csrf import csrf_exempt
from rest_framework.renderers import JSONRenderer
from rest_framework.parsers import JSONParser
from rest_framework import status
from games.models import Game
from games.serializers import GameSerializer
class JSONResponse(HttpResponse):
def __init__(self, data, **kwargs):
content = JSONRenderer().render(data)
kwargs['content_type'] = 'application/json'
super(JSONResponse, self).__init__(content, **kwargs)
@csrf_exempt
def game_list(request):
if request.method == 'GET':
games = Game.objects.all()
games_serializer = GameSerializer(games, many=True)
return JSONResponse(games_serializer.data)
elif request.method == 'POST':
game_data = JSONParser().parse(request)
game_serializer = GameSerializer(data=game_data)
if game_serializer.is_valid():
game_serializer.save()
return JSONResponse(game_serializer.data,
status=status.HTTP_201_CREATED)
return JSONResponse(game_serializer.errors,
status=status.HTTP_400_BAD_REQUEST)
@csrf_exempt
def game_detail(request, pk):
try:
game = Game.objects.get(pk=pk)
except Game.DoesNotExist:
return HttpResponse(status=status.HTTP_404_NOT_FOUND)
if request.method == 'GET':
game_serializer = GameSerializer(game)
return JSONResponse(game_serializer.data)
elif request.method == 'PUT':
game_data = JSONParser().parse(request)
game_serializer = GameSerializer(game, data=game_data)
if game_serializer.is_valid():
game_serializer.save()
return JSONResponse(game_serializer.data)
return JSONResponse(game_serializer.errors,
status=status.HTTP_400_BAD_REQUEST)
elif request.method == 'DELETE':
game.delete()
return HttpResponse(status=status.HTTP_204_NO_CONTENT)
JSONResponse类是django.http.HttpResponse类的子类。超类表示一个以字符串为内容的 HTTP 响应。JSONResponse类将其内容渲染为 JSON。该类仅声明了__init__方法,该方法创建一个rest_framework.renderers.JSONRenderer实例并调用其render方法将接收到的数据渲染为 JSON,并将返回的字节串保存到content局部变量中。然后,代码将'content_type'键添加到响应头中,其值为'application/json'。最后,代码调用基类的初始化器,传递 JSON 字节串和添加到头部的键值对。这样,该类代表了一个我们用于两个函数的 JSON 响应,以便轻松返回 JSON 响应。
代码在两个函数中使用@csrf_exempt装饰器来确保视图设置一个跨站请求伪造(CSRF)cookie。我们这样做是为了简化测试这个示例,因为这个示例不代表一个生产就绪的 Web 服务。我们将在稍后的 RESTful API 中添加安全功能。
当 Django 服务器接收到 HTTP 请求时,Django 创建一个HttpRequest实例,具体是django.http.HttpRequest对象。此实例包含有关请求的元数据,包括 HTTP 动词。method属性提供了一个表示请求中使用的 HTTP 动词或方法的字符串。
当 Django 加载将处理请求的适当视图时,它将HttpRequest实例作为第一个参数传递给视图函数。视图函数必须返回一个HttpResponse实例,具体是django.http.HttpResponse实例。
game_list 函数列出所有游戏或创建一个新的游戏。该函数接收一个 HttpRequest 实例作为 request 参数。该函数能够处理两种 HTTP 动词:GET 和 POST。代码会检查 request.method 属性的值,以确定根据 HTTP 动词要执行哪个代码。如果 HTTP 动词是 GET,则表达式 request.method == 'GET' 将评估为 True,代码必须列出所有游戏。代码将从数据库检索所有 Game 对象,使用 GameSerializer 将它们全部序列化,并返回一个使用 GameSerializer 生成数据的 JSONResponse 实例。代码使用 many=True 参数创建 GameSerializer 实例,以指定必须序列化多个实例,而不仅仅是单个实例。在底层,当 many 参数值设置为 True 时,Django 使用 ListSerializer。
如果 HTTP 动词是 POST,则代码必须根据包含在 HTTP 请求中的 JSON 数据创建一个新的游戏。首先,代码使用一个 JSONParser 实例,并使用请求作为参数调用其 parse 方法,以解析请求中提供的作为 JSON 数据的游戏数据,并将结果保存在 game_data 本地变量中。然后,代码使用之前检索到的数据创建一个 GameSerializer 实例,并调用 is_valid 方法以确定 Game 实例是否有效。如果实例有效,代码将调用 save 方法将实例持久化到数据库中,并返回一个包含保存数据的 JSONResponse 实例和状态等于 status.HTTP_201_CREATED 的状态,即 201 Created。
小贴士
无论何时我们需要返回与默认 200 OK 状态不同的特定状态,使用 rest_framework.status 模块中定义的模块变量都是一个好习惯,并避免使用硬编码的数值。
game_detail 函数检索、更新或删除现有的游戏。该函数接收一个 HttpRequest 实例作为 request 参数,以及要检索、更新或删除的游戏的主键或标识符作为 pk 参数。该函数能够处理三种 HTTP 动词:GET、PUT 和 DELETE。代码会检查 request.method 属性的值,以确定根据 HTTP 动词要执行哪个代码。无论 HTTP 动词是什么,该函数都会调用 Game.objects.get 方法,将接收到的 pk 作为 pk 参数,从数据库中根据指定的主键或标识符检索一个 Game 实例,并将其保存在 game 本地变量中。如果数据库中不存在具有指定主键或标识符的游戏,代码将返回一个状态等于 status.HTTP_404_NOT_FOUND 的 HttpResponse,即 404 Not Found。
如果 HTTP 动词是 GET,代码会创建一个带有 game 参数的 GameSerializer 实例,并在一个包含默认 200 OK 状态的 JSONResponse 中返回序列化游戏的 数据。代码返回检索到的游戏序列化为 JSON 格式的数据。
如果 HTTP 动词是 PUT,代码必须根据包含在 HTTP 请求中的 JSON 数据创建一个新的游戏,并使用它来替换现有的游戏。首先,代码使用一个 JSONParser 实例,并调用其 parse 方法,将请求作为参数来解析请求中提供的 JSON 数据,并将结果保存到 game_data 本地变量中。然后,代码创建一个带有从数据库中先前检索到的 Game 实例(game)和将替换现有数据(game_data)的检索数据的 GameSerializer 实例。然后,代码调用 is_valid 方法来确定 Game 实例是否有效。如果实例有效,代码会调用 save 方法以替换的值在数据库中持久化实例,并返回一个包含保存数据的 JSONResponse 和默认的 200 OK 状态。如果解析的数据没有生成有效的 Game 实例,代码会返回一个状态等于 status.HTTP_400_BAD_REQUEST 的 JSONResponse,即 400 Bad Request。
如果 HTTP 动词是 DELETE,代码会调用之前从数据库中检索到的 Game 实例(game)的 delete 方法。调用 delete 方法会删除 games_game 表中的底层行,因此游戏将不再可用。然后,代码返回一个状态等于 status.HTTP_204_NO_CONTENT 的 JSONResponse,即 204 No Content。
现在,我们必须在 games 文件夹中创建一个名为 urls.py 的新 Python 文件,具体是 games/urls.py 文件。以下行显示了该文件的代码,该代码定义了 URL 模式,指定了请求中必须匹配的正则表达式,以运行在 views.py 文件中定义的特定函数。示例的代码文件包含在 restful_python_chapter_01_01 文件夹中:
from django.conf.urls import url
from games import views
urlpatterns = [
url(r'^games/$', views.game_list),
url(r'^games/(?P<pk>[0-9]+)/$', views.game_detail),
]
urlpatterns 列表使得将 URL 路由到视图成为可能。代码通过调用 django.conf.urls.url 函数,并传入需要匹配的正则表达式和定义在视图模块中的视图函数作为参数,为 urlpatterns 列表中的每个条目创建一个 RegexURLPattern 实例。
我们必须替换 gamesapi 文件夹中的 urls.py 文件中的代码,具体来说,是 gamesapi/urls.py 文件。该文件定义了根 URL 配置,因此我们必须包含在先前编写的 games/urls.py 文件中声明的 URL 模式。以下行显示了 gamesapi/urls.py 文件的新代码。示例的代码文件包含在 restful_python_chapter_01_01 文件夹中:
from django.conf.urls import url, include
urlpatterns = [
url(r'^', include('games.urls')),
]
现在,我们可以启动 Django 的开发服务器,以编写并发送 HTTP 请求到我们的不安全 Web API(我们肯定会稍后添加安全性)。执行以下命令:
python manage.py runserver
以下行显示了执行前面的命令后的输出。开发服务器正在监听端口 8000。
Performing system checks...
System check identified no issues (0 silenced).
May 20, 2016 - 04:22:38
Django version 1.9.6, using settings 'gamesapi.settings'
Starting development server at http://127.0.0.1:8000/
Quit the server with CONTROL-C.
使用前面的命令,我们将启动 Django 开发服务器,并且我们只能在我们的开发计算机上访问它。前面的命令在默认 IP 地址上启动开发服务器,即 127.0.0.1 (localhost)。从我们局域网上的其他计算机或设备无法访问此 IP 地址。因此,如果我们想从连接到我们局域网的其他计算机或设备向我们的 API 发送 HTTP 请求,我们应该使用开发计算机的 IP 地址,0.0.0.0(对于 IPv4 配置),或 ::(对于 IPv6 配置)作为开发服务器的期望 IP 地址。
如果我们为 IPv4 配置指定 0.0.0.0 作为期望的 IP 地址,开发服务器将在端口 8000 上监听所有接口。当我们为 IPv6 配置指定 :: 时,它将产生相同的效果。此外,有必要在我们的防火墙(软件和/或硬件)中打开默认端口 8000 并配置端口转发到运行开发服务器的计算机。以下命令以 IPv4 配置启动 Django 的开发服务器,并允许来自我们局域网上的其他计算机和设备的请求:
python manage.py runserver 0.0.0.0:8000
小贴士
如果您决定从连接到局域网的其他计算机或设备编写并发送 HTTP 请求,请记住,您必须使用分配给开发计算机的 IP 地址而不是 localhost。例如,如果计算机的分配 IPv4 IP 地址是 192.168.1.106,则应使用 192.168.1.106:8000 而不是 localhost:8000。当然,您也可以使用主机名而不是 IP 地址。之前解释的配置非常重要,因为移动设备可能是我们 RESTful API 的消费者,我们总是希望在开发环境中测试使用我们 API 的应用程序。
向 API 发送 HTTP 请求
Django 开发服务器正在本地主机 (127.0.0.1) 上运行,监听端口 8000,等待我们的 HTTP 请求。现在,我们将在我们开发计算机本地或从连接到局域网的其他计算机或设备上编写并发送 HTTP 请求。我们将使用本书中介绍的不同类型的工具来编写并发送 HTTP 请求。
-
命令行工具
-
图形界面工具
-
Python 代码
-
JavaScript 代码
小贴士
注意,您可以使用任何其他允许您编写并发送 HTTP 请求的应用程序。有许多在平板电脑和智能手机上运行的应用程序允许您完成此任务。然而,我们将关注构建 RESTful Web API 时最有用的工具。
使用命令行工具 - curl 和 httpie
我们将从命令行工具开始。命令行工具的一个关键优势是,我们可以在第一次构建 HTTP 请求后轻松地再次运行它们,而无需使用鼠标或触摸屏幕来运行请求。我们还可以轻松地构建一个包含批量请求的脚本并运行它们。与任何命令行工具一样,与 GUI 工具相比,执行第一次请求可能需要更多时间,但一旦我们执行了许多请求,我们就可以轻松地重用我们以前编写的命令来组合新的请求。
Curl,也称为 cURL,是一个非常流行的开源命令行工具和库,它使我们能够轻松地传输数据。我们可以使用 cURL 命令行工具轻松地组合和发送 HTTP 请求,并检查它们的响应。
小贴士
如果你正在 macOS 或 Linux 上工作,你可以打开终端并从命令行开始使用 cURL。如果你正在 Windows 的任何版本上工作,你可以轻松地从Cygwin包安装选项中安装 cURL,并在 Cygwin 终端中执行它。你可以在curl.haxx.se上了解更多关于 cURL 实用程序的信息。你可以在cygwin.com/install.html上了解更多关于 Cygwin 终端及其安装过程的信息。
在 Windows 中打开 Cygwin 终端或在 macOS 或 Linux 中打开终端,并运行以下命令。非常重要的一点是,你必须输入结束斜杠(/),因为/games不会匹配games/urls.py文件中指定的任何模式。我们正在使用 Django 的默认配置,该配置不会将不匹配任何模式的 URL 重定向到带有附加斜杠的相同 URL。因此,我们必须输入/games/,包括结束斜杠(/):
curl -X GET :8000/games/
前面的命令将组合并发送以下 HTTP 请求-GET http://localhost:8000/games/。这个请求是我们 RESTful API 中最简单的情况,因为它将匹配并运行views.game_list函数,即games/views.py文件中声明的game_list函数。该函数仅接收request作为参数,因为 URL 模式不包含任何参数。由于请求的 HTTP 动词是GET,因此request.method属性等于'GET',因此该函数将执行检索所有Game对象的代码,并生成包含所有这些序列化Game对象的 JSON 响应。
以下行显示了 HTTP 请求的一个示例响应,其中 JSON 响应中有三个Game对象:
[{"pk":3,"name":"Angry Birds RPG","release_date":"2016-05-18T03:02:00.776594Z","game_category":"3D RPG","played":false},{"pk":2,"name":"Smurfs Jungle","release_date":"2016-05-18T03:02:00.776594Z","game_category":"2D mobile arcade","played":false},{"pk":11,"name":"Tomb Raider Extreme Edition","release_date":"2016-05-18T03:02:00.776594Z","game_category":"3D RPG","played":false}]
如我们从之前的响应中注意到的,curl 实用程序将 JSON 响应显示为单行,因此它有点难以阅读。在这种情况下,我们知道响应的Content-Type是application/json。然而,如果我们想了解更多关于响应的详细信息,我们可以使用-i选项请求 curl 打印 HTTP 响应头。我们可以通过使用-iX将-i和-X选项组合起来。
返回到 Windows 中的 Cygwin 终端或 macOS 或 Linux 中的终端,并运行以下命令:
curl -iX GET :8000/games/
以下几行显示了 HTTP 请求的一个示例响应。前几行显示了 HTTP 响应头,包括状态(200 OK)和Content-type(application/json)。在 HTTP 响应头之后,我们可以看到 JSON 响应中三个Game对象的详细信息:
HTTP/1.0 200 OK
Date: Tue, 24 May 2016 18:04:40 GMT
Server: WSGIServer/0.2 CPython/3.5.1
Content-Type: application/json
X-Frame-Options: SAMEORIGIN
[{"pk":3,"name":"Angry Birds RPG","release_date":"2016-05-18T03:02:00.776594Z","game_category":"3D RPG","played":false},{"pk":2,"name":"Smurfs Jungle","release_date":"2016-05-18T03:02:00.776594Z","game_category":"2D mobile arcade","played":false},{"pk":11,"name":"Tomb Raider Extreme Edition","release_date":"2016-05-18T03:02:00.776594Z","game_category":"3D RPG","played":false}]
在我们运行两个请求之后,我们将在运行 Django 开发服务器的窗口中看到以下几行。输出表明服务器接收了两个带有GET动词和/games/作为 URI 的 HTTP 请求。服务器处理了这两个 HTTP 请求,返回状态码 200,响应长度等于 379 个字符。响应长度可能不同,因为分配给每个游戏的每个主键的值将对响应长度产生影响。HTTP/1.1."之后的第一个数字表示返回的状态码(200),第二个数字表示响应长度(379)。
[25/May/2016 04:35:09] "GET /games/ HTTP/1.1" 200 379
[25/May/2016 04:35:10] "GET /games/ HTTP/1.1" 200 379
以下图像显示了 macOS 上并排的两个终端窗口。左侧的终端窗口正在运行 Django 开发服务器,并显示接收和处理的 HTTP 请求。右侧的终端窗口正在运行curl命令来生成 HTTP 请求。
在我们编写和发送 HTTP 请求时,使用类似的配置来检查输出是个好主意。请注意,JSON 输出有点难以阅读,因为它们没有使用语法高亮:

现在,我们将安装 HTTPie,这是一个用 Python 编写的命令行 HTTP 客户端,它使得发送 HTTP 请求变得简单,并且使用的语法比 curl(也称为 cURL)更容易。HTTPie 的一个巨大优点是它显示彩色输出,并使用多行来显示响应细节。因此,HTTPie 比 curl 实用程序更容易理解响应。我们只需要激活虚拟环境,然后在终端或命令提示符中运行以下命令来安装 HTTPie 包:
pip install --upgrade httpie
输出的最后几行将指示django包已成功安装。
Collecting httpie
Downloading httpie-0.9.3-py2.py3-none-any.whl (66kB)
Collecting requests>=2.3.0 (from httpie)
Using cached requests-2.10.0-py2.py3-none-any.whl
Collecting Pygments>=1.5 (from httpie)
Using cached Pygments-2.1.3-py2.py3-none-any.whl
Installing collected packages: requests, Pygments, httpie
Successfully installed Pygments-2.1.3 httpie-0.9.3 requests-2.10.0
小贴士
如果你不记得如何激活为我们这个示例创建的虚拟环境,请阅读本章以下部分-使用 Django REST 框架设置虚拟环境。
现在,我们可以使用 http 命令轻松地编写并发送 HTTP 请求到 localhost:8000 并测试使用 Django REST 框架构建的 RESTful API。HTTPie 支持类似于 curl 的本地主机缩写,因此我们可以使用 :8000 作为缩写,它展开为 http://localhost:8000。运行以下命令并记得输入结束斜杠(/):
http :8000/games/
前面的命令将编写并发送以下 HTTP 请求:GET http://localhost:8000/games/。请求与之前使用 curl 命令编写的请求相同。然而,在这种情况下,HTTPie 工具将显示彩色输出,并使用多行来显示 JSON 响应。前面的命令等同于以下命令,该命令在 http 后指定 GET 方法:
http GET :8000/games/
以下行显示了对 HTTP 请求的示例响应,包括头部信息和 JSON 响应中的三个 Game 对象。与使用 curl 编写 HTTP 请求时生成的结果相比,理解响应确实更容易。HTTPie 自动格式化接收到的 JSON 数据作为响应,并应用语法高亮,具体来说,包括颜色和格式:
HTTP/1.0 200 OK
Content-Type: application/json
Date: Thu, 26 May 2016 21:33:17 GMT
Server: WSGIServer/0.2 CPython/3.5.1
X-Frame-Options: SAMEORIGIN
[
{
"game_category": "3D RPG",
"name": "Angry Birds RPG",
"pk": 3,
"played": false,
"release_date": "2016-05-18T03:02:00.776594Z"
},
{
"game_category": "2D mobile arcade",
"name": "Smurfs Jungle",
"pk": 2,
"played": false,
"release_date": "2016-05-18T03:02:00.776594Z"
},
{
"game_category": "3D RPG",
"name": "Tomb Raider Extreme Edition",
"pk": 11,
"played": false,
"release_date": "2016-05-18T03:02:00.776594Z"
}
]
小贴士
我们可以通过将 curl 命令生成的输出与其他工具结合来达到相同的结果。然而,HTTPie 提供了我们与 RESTful API 一起工作所需的一切。我们将使用 HTTPie 来编写并发送 HTTP 请求,但我们将始终提供等效的 curl 命令。
以下图像显示了 macOS 上并排的两个终端窗口。左侧的终端窗口正在运行 Django 开发服务器,并显示接收和处理的 HTTP 请求。右侧的终端窗口正在运行 HTTPie 命令以生成 HTTP 请求。请注意,与 curl 命令生成的输出相比,JSON 输出更容易阅读:

如果我们不希望在响应中包含头部信息,可以使用 -b 选项执行 HTTPie。例如,以下行执行了相同的 HTTP 请求,但不会在响应输出中显示头部信息,因此输出将仅显示 JSON 响应:
http -b :8000/games/
现在,我们将从前面的列表中选择一个游戏,并编写一个 HTTP 请求来检索所选的游戏。例如,在前面的列表中,第一个游戏的 pk 值等于 3。运行以下命令来检索此游戏。使用您在先前的命令中检索到的第一个游戏的 pk 值,因为 pk 数字可能不同:
http :8000/games/3/
以下是对应的 curl 命令:
curl -iX GET :8000/games/3/
之前的命令将构建并发送以下 HTTP 请求:GET http://localhost:8000/games/3/。请求在/games/之后有一个数字,因此,它将匹配'^games/(?P<pk>[0-9]+)/$'并运行views.game_detail函数,即games/views.py文件中声明的game_detail函数。该函数接收request和pk作为参数,因为 URL 模式将/games/之后指定的数字作为pk参数传递。由于请求的 HTTP 动词是GET,所以request.method属性等于'GET',因此,该函数将执行检索与作为参数接收的pk值匹配的Game对象的代码,如果找到,则生成一个包含此Game对象的序列化 JSON 响应。以下行显示了 HTTP 请求的示例响应,其中 JSON 响应中匹配pk值的Game对象:
HTTP/1.0 200 OK
Content-Type: application/json
Date: Fri, 27 May 2016 02:28:30 GMT
Server: WSGIServer/0.2 CPython/3.5.1
X-Frame-Options: SAMEORIGIN
{
"game_category": "3D RPG",
"name": "Angry Birds RPG",
"pk": 3,
"played": false,
"release_date": "2016-05-18T03:02:00.776594Z"
}
现在,我们将构建并发送一个 HTTP 请求来检索一个不存在的游戏。例如,在前面列表中,没有pk值等于99999的游戏。运行以下命令尝试检索此游戏。确保您使用一个不存在的pk值。我们必须确保工具将头信息作为响应的一部分显示,因为响应不会有主体部分:
http :8000/games/99999/
以下是对应的 curl 命令:
curl -iX GET :8000/games/99999/
前面的命令将构建并发送以下 HTTP 请求:GET http://localhost:8000/games/99999/。该请求与之前分析过的请求相同,只是pk参数的数字不同。服务器将运行views.game_detail函数,即games/views.py文件中声明的game_detail函数。该函数将执行检索与作为参数接收的pk值匹配的Game对象的代码,并抛出并捕获Game.DoesNotExist异常,因为没有与指定的pk值匹配的游戏。因此,代码将返回 HTTP 404 Not Found 状态码。以下行显示了 HTTP 请求的示例响应头:
HTTP/1.0 404 Not Found
Content-Type: text/html; charset=utf-8
Date: Fri, 27 May 2016 02:20:41 GMT
Server: WSGIServer/0.2 CPython/3.5.1
X-Frame-Options: SAMEORIGIN
我们将构建并发送一个 HTTP 请求来创建一个新的游戏。
http POST :8000/games/ name='PvZ 3' game_category='2D mobile arcade' played=false release_date='2016-05-18T03:02:00.776594Z'
以下是对应的 curl 命令。非常重要的一点是使用-H "Content-Type: application/json"选项来指示 curl 将-d 选项之后指定的数据作为application/json发送,而不是默认的application/x-www-form-urlencoded:
curl -iX POST -H "Content-Type: application/json" -d '{"name":"PvZ 3", "game_category":"2D mobile arcade", "played": "false", "release_date": "2016-05-18T03:02:00.776594Z"}' :8000/games/
之前的命令将构建并发送以下 HTTP 请求:POST http://localhost:8000/games/,带有以下 JSON 键值对:
{
"name": "PvZ 3",
"game_category": "2D mobile arcade",
"played": false,
"release_date": "2016-05-18T03:02:00.776594Z"
}
请求指定了 /games/,因此,它将匹配 '^games/$' 并运行 views.game_list 函数,即 games/views.py 文件中声明的 game_detail 函数。该函数仅接收 request 作为参数,因为 URL 模式不包含任何参数。由于请求的 HTTP 方法为 POST,request.method 属性等于 'POST',因此,该函数将执行解析请求中接收到的 JSON 数据的代码,创建一个新的 Game 对象,如果数据有效,则保存新的 Game。如果新的 Game 成功持久化到数据库中,该函数将返回一个 HTTP 201 Created 状态码,并将最近持久化的 Game 对象序列化为 JSON 格式放在响应体中。以下行显示了 HTTP 请求的示例响应,其中包含 JSON 响应中的新 Game 对象:
HTTP/1.0 201 Created
Content-Type: application/json
Date: Fri, 27 May 2016 05:12:39 GMT
Server: WSGIServer/0.2 CPython/3.5.1
X-Frame-Options: SAMEORIGIN
{
"game_category": "2D mobile arcade",
"name": "PvZ 3",
"pk": 15,
"played": false,
"release_date": "2016-05-18T03:02:00.776594Z"
}
现在,我们将编写并发送一个 HTTP 请求来更新一个现有的游戏,具体来说,是之前添加的游戏。我们必须检查之前响应中分配给 pk 的值,并将命令中的 15 替换为返回的值。例如,如果 pk 的值为 5,则应使用 :8000/games/5/ 而不是 :8000/games/15/。
http PUT :8000/games/15/ name='PvZ 3' game_category='2D mobile arcade' played=true release_date='2016-05-20T03:02:00.776594Z'
以下是与之前 curl 示例等效的 curl 命令。与之前的 curl 示例一样,非常重要的一点是使用 -H "Content-Type: application/json" 选项来指示 curl 将 -d 选项之后指定的数据作为 application/json 发送,而不是默认的 application/x-www-form-urlencoded:
curl -iX PUT -H "Content-Type: application/json" -d '{"name":"PvZ 3", "game_category":"2D mobile arcade", "played": "true", "release_date": "2016-05-20T03:02:00.776594Z"}' :8000/games/15/
之前的命令将编写并发送以下 HTTP 请求:PUT http://localhost:8000/games/15/,并带有以下 JSON 键值对:
{
"name": "PvZ 3",
"game_category": "2D mobile arcade",
"played": true,
"release_date": "2016-05-20T03:02:00.776594Z"
}
请求在 /games/ 后面有一个数字,因此,它将匹配 '^games/(?P<pk>[0-9]+)/$' 并运行 views.game_detail 函数,即 games/views.py 文件中声明的 game_detail 函数。该函数接收 request 和 pk 作为参数,因为 URL 模式将 /games/ 后面指定的数字传递给 pk 参数。由于请求的 HTTP 方法为 PUT,request.method 属性等于 'PUT',因此,该函数将执行解析请求中接收到的 JSON 数据的代码,从这些数据创建一个 Game 实例,并更新数据库中的现有游戏。如果游戏在数据库中成功更新,该函数将返回一个 HTTP 200 OK 状态码,并将最近更新的 Game 对象序列化为 JSON 格式放在响应体中。以下行显示了 HTTP 请求的示例响应,其中包含 JSON 响应中的更新后的 Game 对象:
HTTP/1.0 200 OK
Content-Type: application/json
Date: Sat, 28 May 2016 00:49:05 GMT
Server: WSGIServer/0.2 CPython/3.5.1
X-Frame-Options: SAMEORIGIN
{
"game_category": "2D mobile arcade",
"name": "PvZ 3",
"pk": 15,
"played": true,
"release_date": "2016-05-20T03:02:00.776594Z"
}
为了成功处理更新现有游戏的 PUT HTTP 请求,我们必须为所有必需的字段提供值。我们将组合并发送一个 HTTP 请求来尝试更新一个现有游戏,我们将无法做到这一点,因为我们只为名称提供了一个值。正如在先前的请求中所发生的那样,我们将使用我们在最后添加的游戏中分配给 pk 的值:
http PUT :8000/games/15/ name='PvZ 4'
以下是对应的 curl 命令:
curl -iX PUT -H "Content-Type: application/json" -d '{"name":"PvZ 4"}'
:8000/games/15/
之前的命令将组合并发送以下 HTTP 请求:PUT http://localhost:8000/games/15/,并带有以下 JSON 键值对:
{
"name": "PvZ 4",
}
请求将执行我们之前解释的相同代码。因为我们没有为 Game 实例提供所有必需的值,所以 game_serializer.is_valid() 方法将返回 False,函数将返回 HTTP 400 Bad Request 状态码,并将 game_serializer.errors 属性中生成的详细信息序列化为 JSON 放在响应体中。以下行显示了缺少 JSON 响应中必需字段的 HTTP 请求的示例响应:
HTTP/1.0 400 Bad Request
Content-Type: application/json
Date: Sat, 28 May 2016 02:53:08 GMT
Server: WSGIServer/0.2 CPython/3.5.1
X-Frame-Options: SAMEORIGIN
{
"game_category": [
"This field is required."
],
"release_date": [
"This field is required."
]
}
小贴士
当我们希望我们的 API 能够更新现有资源的单个字段,在这种情况下,一个现有的游戏,我们应该提供一个 PATCH 方法的实现。PUT 方法旨在替换整个资源,而 PATCH 方法旨在对现有资源应用一个增量。我们可以在 PUT 方法的处理程序中编写代码来对现有资源应用增量,但使用 PATCH 方法执行此特定任务是一种更好的做法。我们将在稍后使用 PATCH 方法。
现在,我们将组合并发送一个 HTTP 请求来删除一个现有的游戏,具体来说,是我们最后添加的游戏。正如我们在上一个 HTTP 请求中所做的那样,我们必须检查上一个响应中分配给 pk 的值,并将命令中的 12 替换为返回的值:
http DELETE :8000/games/15/
以下是对应的 curl 命令:
curl -iX DELETE :8000/games/15/
上述命令将组合并发送以下 HTTP 请求:DELETE http://localhost:8000/games/15/。请求在 /games/ 后有一个数字,因此它将匹配 '^games/(?P<pk>[0-9]+)/$' 并运行 views.game_detail 函数,即 games/views.py 文件中声明的 game_detail 函数。该函数接收 request 和 pk 作为参数,因为 URL 模式将 /games/ 后指定的数字传递给 pk 参数。由于请求的 HTTP 动词是 DELETE,因此 request.method 属性等于 'DELETE',因此该函数将执行解析请求中接收到的 JSON 数据的代码,从这些数据创建一个 Game 实例,并删除数据库中的现有游戏。如果游戏在数据库中成功删除,则函数返回 HTTP 204 No Content 状态码。以下行显示了成功删除现有游戏后的 HTTP 请求的示例响应:
HTTP/1.0 204 No Content
Date: Sat, 28 May 2016 04:08:58 GMT
Server: WSGIServer/0.2 CPython/3.5.1
Content-Length: 0
X-Frame-Options: SAMEORIGIN
Content-Type: text/html; charset=utf-8
使用 GUI 工具 - Postman 和其他工具
到目前为止,我们一直在使用两个基于终端或命令行的工具来编写和发送 HTTP 请求到我们的 Django 开发服务器——cURL 和 HTTPie。现在,我们将使用GUI(图形用户界面)工具。
Postman 是一个非常流行的 API 测试套件 GUI 工具,它允许我们轻松地编写和发送 HTTP 请求,以及其他功能。Postman 可以作为 Chrome 应用和 Mac 应用使用。我们可以在 Windows、Linux 和 macOS 上作为 Chrome 应用执行它,即运行在 Google Chrome 之上的应用程序。如果我们使用 macOS,我们可以使用 Mac 应用而不是 Chrome 应用。您可以从以下 URL 下载 Postman 应用的版本-www.getpostman.com。
小贴士
您可以免费下载并安装 Postman 来编写和发送 HTTP 请求到我们的 RESTful API。您只需在 Postman 上注册,我们不会在我们的示例中使用 Postman 云提供的任何付费功能。所有说明都适用于 Postman 4.2.2 或更高版本。
现在,我们将使用 Postman 中的构建器选项卡轻松地编写和发送 HTTP 请求到localhost:8000,并使用此 GUI 工具测试 RESTful API。Postman 不支持 curl-like 的本地主机缩写,因此我们无法在编写 HTTPie 请求时使用相同的缩写。
在“输入请求 URL”文本框左侧的下拉菜单中选择GET,然后在下拉菜单右侧的文本框中输入localhost:8000/games/。然后点击发送,Postman 将显示状态(200 OK)、请求处理所需的时间和响应体,其中所有游戏都格式化为带有语法高亮的 JSON(美化视图)。
以下截图显示了 Postman 中 HTTP GET 请求的 JSON 响应体:

点击Body和Cookies右侧的头部,以读取响应头部。以下截图显示了 Postman 为前面的响应显示的响应头部布局。注意,Postman 在响应右侧显示状态,并且不将其作为头部的第一行,就像我们在使用 cURL 和 HTTPie 工具时发生的那样:

现在,我们将使用 Postman 中的构建器选项卡来编写和发送一个 HTTP 请求以创建一个新的游戏,具体来说,是一个 POST 请求。按照以下步骤操作:
-
在“输入请求 URL”文本框左侧的下拉菜单中选择POST,然后在下拉菜单右侧的文本框中输入
localhost:8000/games/。 -
在编写请求的面板中,点击Body右侧的授权和头部。
-
激活 raw 单选按钮,并在 binary 单选按钮右侧的下拉菜单中选择 JSON (application/json)。Postman 将自动添加一个 Content-type 为 application/json 的头部,因此,您会注意到 Headers 选项卡将被重命名为 Headers (1),这表示我们已指定了一个请求头部的键值对。
-
在单选按钮下方的文本框中输入以下行,位于 Body 选项卡内:
{
"name": "Batman vs Superman",
"game_category": "3D RPG",
"played": false,
"release_date": "2016-05-18T03:02:00.776594Z"
}
以下截图显示了 Postman 中的请求体:

我们遵循了创建具有 JSON 体的 HTTP POST 请求的必要步骤,该请求指定了创建新游戏所需的关键值对。点击 Send,Postman 将显示状态(201 Created)、请求处理所需的时间以及以语法高亮(Pretty 视图)格式化的响应体。以下截图显示了 Postman 中 HTTP POST 请求的 JSON 响应体。
小贴士
如果我们想使用 Postman 编写和发送一个 HTTP PUT 请求,必须遵循之前解释的步骤,在请求体中提供 JSON 数据。
Postman 包含的一个不错的特点是,我们可以通过浏览 Postman 窗口左侧显示的已保存 History 来轻松地回顾和再次运行我们已发送的 HTTP 请求。历史记录面板显示了一个列表,其中包含我们已编写和发送的每个 HTTP 请求的 HTTP 动词和 URL。我们只需点击所需的 HTTP 请求,然后点击 Send 再次运行它。以下截图显示了 History 面板中的许多 HTTP 请求以及第一个被选中以再次发送的请求。
JetBrains PyCharm 是一个非常流行的多平台 Python 集成开发环境(简称 IDE),可在 macOS、Linux 和 Windows 上使用。其付费专业版包含一个 REST 客户端,允许我们测试 RESTful Web 服务。如果我们使用这个版本的 IDE,我们可以在不离开 IDE 的情况下编写并发送 HTTP 请求。您不需要 JetBrains PyCharm 专业版许可证来运行本书中包含的示例。然而,由于 IDE 非常受欢迎,我们将学习使用该 IDE 中包含的 REST 客户端来编写和发送 HTTP 请求的必要步骤。
现在,我们将使用 PyCharm 专业版中包含的 REST 客户端来编写并发送一个 HTTP 请求以创建一个新的游戏,具体来说,是一个 POST 请求。按照以下步骤操作:
-
在主菜单中选择 Tools | Test RESTful Web Service 以显示 REST 客户端面板。
-
在 REST 客户端面板中的 HTTP 方法下拉菜单中选择 POST。
-
在 Host/port 文本框中输入
localhost:8000,位于下拉菜单的右侧。 -
在 Path 文本框中输入
/games/,位于 Host/port 文本框的右侧。 -
确保激活 请求 选项卡,然后点击 Headers 列表底部的添加 (+) 按钮。IDE 将显示一个用于名称的文本框和一个用于值的下拉菜单。在 名称 中输入
Content-Type,在 值 中输入application/json并按 Enter 键。 -
在 请求体 中激活 文本 单选按钮,并点击位于 文本 文本框右侧的 ... 按钮,以指定要发送的文本。在 指定要发送的文本 对话框中的文本框中输入以下行,然后点击 确定。
{
"name": "Teenage Mutant Ninja Turtles",
"game_category": "3D RPG",
"played": false,
"release_date": "2016-05-18T03:02:00.776594Z"
}
以下截图显示了 PyCharm Professional REST 客户端 中构建的请求。

我们遵循了必要的步骤来创建一个具有 JSON 体的 HTTP POST 请求,该体指定了创建新游戏所需的关键值对。点击提交请求按钮,即位于 REST 客户端 窗口右上角的带有播放图标的第一个按钮。REST 客户端将编写并发送 HTTP POST 请求,激活 响应 选项卡,并在窗口底部显示响应代码 201 (已创建)、请求处理所需的时间以及内容长度。
默认情况下,REST 客户端将自动应用 JSON 语法高亮显示到响应中。但是,有时 JSON 内容会显示为没有换行,此时需要点击重格式化响应按钮,即位于 响应 选项卡中的第一个按钮。REST 客户端在另一个选项卡中显示响应头,因此它仅在 响应 选项卡中显示响应体。以下截图显示了 REST 客户端中 HTTP POST 请求的 JSON 响应体。

小贴士
如果我们想在 PyCharm Professional 中使用 REST 客户端编写和发送 HTTP PUT 请求,则需要遵循之前解释的步骤,在请求体中提供 JSON 数据。
如果您不使用 PyCharm Professional,运行以下任何命令来编写和发送创建新游戏的 HTTP POST 请求:
http POST :8000/games/ name='Teenage Mutant Ninja Turtles' game_category='3D RPG' played=false release_date='2016-05-18T03:02:00.776594Z'
以下是对应的 curl 命令:
curl -iX POST -H "Content-Type: application/json" -d '{"name": "Teenage
Mutant Ninja Turtles", "game_category": "3D RPG", "played": "false",
"release_date": "2016-05-18T03:02:00.776594Z"}' :8000/games/
Telerik Fiddler 是 Windows 开发者常用的工具。Telerik Fiddler 是一款免费的 Web 调试代理工具,具有图形用户界面,但只能在 Windows 上运行。它的主要网页将其宣传为多平台工具,但在本书出版时,macOS 和 Linux 版本完全不稳定,并且其开发已被放弃。我们可以使用 Windows 上的 Telerik Fiddler 来编写和发送 HTTP 请求,以及其他功能。您可以从以下网址下载 Fiddler for Windows -www.telerik.com/download/fiddler。
Stoplight 是一个流行的强大 API 模型工具,它允许我们轻松测试我们的 API。其 HTTP 请求生成器允许我们编写和发送请求,并生成在不同编程语言中(如 JavaScript、Swift、C#、PHP、Node 和 Go 等)执行这些请求的必要代码。您可以在以下网址注册使用 Stoplight - stoplight.io。
我们还可以使用能够从移动设备编写和发送 HTTP 请求的应用程序来与 RESTful API 一起工作。例如,我们可以在 iOS 设备(如 iPad 和 iPhone)上使用 iCurlHTTP 应用程序 - itunes.apple.com/us/app/icurlhttp/id611943891?mt=8。在 Android 设备上,我们可以使用 HTTP Request 应用程序 - play.google.com/store/apps/details?id=air.http.request&hl=en。
以下截图显示了使用 iCurlHTTP 应用程序编写和发送以下 HTTP 请求的结果:GET http://192.168.1.106:8000/games/。请记住,您必须在您的局域网和路由器中执行之前解释的配置,才能从连接到您的局域网的其它设备访问 Django 开发服务器。在这种情况下,运行 Django Web 服务器的计算机分配的 IP 地址是 192.168.1.106,因此,您必须将此 IP 地址替换为您开发计算机分配的 IP 地址。
在本书出版时,允许您编写和发送 HTTP 请求的移动应用程序并不提供您在 Postman 或命令行工具中可以找到的所有功能。

测试您的知识
-
如果我们想创建一个简单的
Player模型,我们将使用它来表示和持久化 Django REST 框架中的玩家,我们可以创建:-
一个作为
djangorestframework.models.Model类子类的Player类。 -
一个作为
django.db.models.Model类子类的Player类。 -
restframeworkmodels.py文件中的一个Player函数。
-
-
在 Django REST 框架中,序列化器是:
-
模型实例和 Python 原语之间的调解者。
-
视图函数和 Python 原语之间的调解者。
-
URL 和视图函数之间的调解者。
-
-
在 Django REST 框架中,解析器和渲染器:
-
作为模型实例和 Python 原语之间的调解者。
-
重置棋盘。
-
作为 Python 原语和 HTTP 请求与响应之间的调解者。
-
-
在
urls.py文件中声明的urlpatterns列使得:-
将路由 URL 映射到视图。
-
将路由 URL 映射到模型。
-
将路由 URL 映射到 Python 原语。
-
-
HTTPie 是一个:
-
用 Python 编写的命令行 HTTP 服务器,它使得创建 RESTful Web 服务器变得容易。
-
命令行实用程序,允许我们对 SQLite 数据库运行查询。
-
用 Python 编写的命令行 HTTP 客户端,它使得编写和发送 HTTP 请求变得容易。
-
摘要
在本章中,我们设计了一个 RESTful API 来与简单的 SQLite 数据库交互,并使用游戏执行 CRUD 操作。我们定义了 API 的需求,并理解了每个 HTTP 方法执行的任务。我们学习了在 Python 中使用轻量级虚拟环境的优势,并使用 Django REST 框架设置了一个虚拟环境。
我们创建了一个模型来表示和持久化游戏,并在 Django 中执行了迁移。我们学习了如何使用 Django REST 框架管理游戏实例的序列化和反序列化到 JSON 表示。我们编写了 API 视图来处理不同的 HTTP 请求,并配置了 URL 模式列表以将 URL 路由到视图。
最后,我们启动了 Django 开发服务器,并使用命令行工具向我们的 RESTful API 组合并发送 HTTP 请求,分析了我们的代码中每个 HTTP 请求的处理方式。我们还使用图形用户界面工具来组合和发送 HTTP 请求。
现在我们已经了解了 Django REST 框架的基础知识,我们将通过利用 Django REST 框架中包含的先进功能来扩展 RESTful Web API 的功能,这是我们将在下一章中讨论的内容。
第二章。在 Django 中使用基于类的视图和超链接 API 工作
在本章中,我们将扩展我们在上一章中开始的 RESTful API 的功能。我们将更改 ORM 设置以使用更强大的 PostgreSQL 数据库,并利用 Django REST Framework 中包含的先进功能,这些功能允许我们减少复杂 API(如基于类的视图)的样板代码。我们将:
-
使用模型序列化器来消除重复代码
-
使用包装器编写 API 视图
-
使用默认解析和渲染选项,并超越 JSON
-
浏览 API
-
设计一个 RESTful API 以与复杂的 PostgreSQL 数据库交互
-
理解每个
HTTP方法执行的任务 -
声明与模型的关系
-
使用关系和超链接管理序列化和反序列化
-
创建基于类的视图并使用通用类
-
与 API 端点一起工作
-
创建和检索相关资源
使用模型序列化器来消除重复代码
GameSerializer类声明了许多与我们在Game模型中使用的相同名称的属性,并重复了信息,例如类型和max_length值。GameSerializer类是rest_framework.serializers.Serializer的子类,它声明了我们将手动映射到适当类型的属性,并重写了create和update方法。
现在,我们将创建一个新版本的GameSerializer类,该类将继承自rest_framework.serializers.ModelSerializer类。ModelSerializer类自动填充了一组默认字段和一组默认验证器。此外,该类还为create和update方法提供了默认实现。
小贴士
如果你有 Django Web 框架的经验,你会注意到Serializer和ModelSerializer类与Form和ModelForm类相似。
现在,前往gamesapi/games文件夹并打开serializers.py文件。将此文件中的代码替换为以下代码,该代码声明了GameSerializer类的新版本。示例代码文件包含在restful_python_chapter_02_01文件夹中:
from rest_framework import serializers
from games.models import Game
class GameSerializer(serializers.ModelSerializer):
class Meta:
model = Game
fields = ('id',
'name',
'release_date',
'game_category',
'played')
新的GameSerializer类声明了一个Meta内部类,该类声明了两个属性:model和fields。model属性指定与序列化器相关的模型,即Game类。fields属性指定一个字符串元组,其值表示我们想要从相关模型中包含在序列化中的字段名称。
在这种情况下,无需重写create或update方法,因为通用行为就足够了。ModelSerializer超类为这两种方法提供了实现。
我们已经减少了在 GameSerializer 类中不需要的样板代码。我们只需要在元组中指定所需的字段集。现在,与游戏字段相关的类型仅包含在 Game 类中。
提示
按 Ctrl + C 退出 Django 开发服务器,并执行以下命令重新启动它:
python manage.py runserver
使用包装器编写 API 视图
我们在 games/views.py 文件中的代码声明了一个 JSONResponse 类和两个基于函数的视图。这些函数在需要返回 JSON 数据时返回 JSONResponse,而在响应只是 HTTP 状态码时返回 django.Http.Response.HttpResponse 实例。
无论 HTTP 请求头中指定的接受内容类型如何,视图函数始终在响应体中提供相同的内容-JSON。运行以下两个命令以检索具有不同 Accept 请求头值(text/html 和 application/json)的所有游戏:
http :8000/games/ Accept:text/html
http :8000/games/ Accept:application/json
以下是对应的 curl 命令:
curl -H 'Accept: text/html' -iX GET :8000/games/
curl -H 'Accept: application/json' -iX GET :8000/games/
前面的命令将组成并发送以下 HTTP 请求:GET http://localhost:8000/games/。第一个命令为 Accept 请求头定义了 text/html 值。第二个命令为 Accept 请求头定义了 application/json 值。
你会注意到这两个命令产生了相同的结果,因此视图函数没有考虑 HTTP 请求头中指定的 Accept 值。这两个命令的响应头将包括以下行:
Content-Type: application/json
第二个请求指定它只接受 text/html,但响应中包含了 JSON 主体,即 application/json 内容。因此,我们的第一个版本的 RESTful API 没有准备好渲染除 JSON 之外的内容。我们将进行一些更改,以使 API 能够渲染其他内容。
无论何时我们对 RESTful API 中资源或资源集合支持的方法有疑问,我们都可以使用 OPTIONS HTTP 动词和资源或资源集合的 URL 组成并发送 HTTP 请求。如果 RESTful API 为资源或资源集合实现了 OPTIONS HTTP 动词,它会在响应的 Allow 头中提供一个以逗号分隔的 HTTP 动词或方法列表,作为其支持值的列表。此外,响应头还将包括有关其他支持选项的附加信息,例如它能够从请求中解析的内容类型以及它能够在响应中呈现的内容类型。
例如,如果我们想知道游戏集合支持哪些 HTTP 动词,我们可以运行以下命令:
http OPTIONS :8000/games/
以下是对应的 curl 命令:
curl -iX OPTIONS :8000/games/
之前的命令将组合并发送以下 HTTP 请求:OPTIONS http://localhost:8000/games/。请求将匹配并运行views.game_list函数,即games/views.py文件中声明的game_list函数。此函数仅在request.method等于'GET'或'POST'时运行代码。在这种情况下,request.method等于'OPTIONS',因此,该函数不会运行任何代码,也不会返回任何响应,特别是,它不会返回HttpResponse实例。因此,我们将在 Django 开发服务器控制台输出中看到以下Internal Server Error:
Internal Server Error: /games/
Traceback (most recent call last):
File "/Users/gaston/Projects/PythonRESTfulWebAPI/Django01/lib/python3.5/site-packages/django/core/handlers/base.py", line 158, in get_response
% (callback.__module__, view_name))
ValueError: The view games.views.game_list didn't return an HttpResponse object. It returned None instead.
[08/Jun/2016 20:21:40] "OPTIONS /games/ HTTP/1.1" 500 49173
以下行显示了输出头部,其中还包括一个包含关于错误详细信息的 HTML 文档,因为 Django 的调试模式已激活。我们收到500 Internal Server Error状态码:
HTTP/1.0 500 Internal Server Error
Content-Type: text/html
Date: Wed, 08 Jun 2016 20:21:40 GMT
Server: WSGIServer/0.2 CPython/3.5.1
X-Frame-Options: SAMEORIGIN
显然,我们希望提供一个更一致的 API,并且我们希望在收到对游戏资源或游戏集合的OPTIONS动词请求时提供准确的响应。
如果我们使用OPTIONS动词向游戏资源发送一个 HTTP 请求,我们将看到相同的错误,并且会有类似的响应,因为views.game_detail函数仅在request.method等于'GET'、'PUT'或'DELETE'时运行代码。
以下命令将在我们尝试查看 id 或主键等于3的游戏资源的选项时产生解释性错误。别忘了将3替换为你配置中现有游戏的某个主键值:
http OPTIONS :8000/games/3/
以下是对应的 curl 命令:
curl -iX OPTIONS :8000/games/3/
我们只需要在games/views.py文件中做一些修改,以解决我们一直在分析的问题,以解决我们的 RESTful API。我们将使用在rest_framework.decorators中声明的@api_view装饰器来处理基于函数的视图。此装饰器允许我们指定我们的函数可以处理的 HTTP 动词。如果需要由视图函数处理的请求的 HTTP 动词不包括在作为@api_view装饰器的http_method_names参数指定的字符串列表中,则默认行为返回405 Method Not Allowed状态码。这样,我们确保每当收到不在我们的函数视图中考虑的 HTTP 动词时,我们不会生成意外的错误,因为装饰器处理了不受支持的 HTTP 动词或方法。
小贴士
在幕后,@api_view装饰器是一个包装器,它将基于函数的视图转换为rest_framework.views.APIView类的子类。这个类是 Django REST Framework 中所有视图的基类。正如我们可能猜测的那样,如果我们想使用基于类的视图,我们可以创建从该类继承的类,我们将获得与使用装饰器的基于函数的视图相同的优势。我们将在接下来的示例中使用基于类的视图。
此外,由于我们指定了一个支持 HTTP 动词的字符串列表,装饰器会自动为支持的 HTTP 动词OPTIONS构建响应,包括支持的方法、解析器和渲染能力。我们实际的 API 版本仅能够渲染 JSON 作为其输出。装饰器的使用确保了在 Django 调用我们的视图函数时,我们总是接收到rest_framework.request.Request类的实例。装饰器还处理当我们的函数视图访问可能引起解析问题的request.data属性时可能发生的ParserError异常。
使用默认的解析和渲染选项,超越 JSON
APIView类为每个视图指定默认设置,我们可以通过在gamesapi/settings.py文件中指定适当的值或在子类中覆盖类属性来覆盖这些设置。如前所述,APIView类在内部使用使装饰器应用这些默认设置。因此,每次我们使用装饰器时,默认解析器类和默认渲染器类都将与函数视图相关联。
默认情况下,DEFAULT_PARSER_CLASSES的值是以下类元组:
(
'rest_framework.parsers.JSONParser',
'rest_framework.parsers.FormParser',
'rest_framework.parsers.MultiPartParser'
)
当我们使用装饰器时,API 将能够通过适当的解析器处理访问request.data属性时可能使用的以下任何内容类型:
-
application/json -
application/x-www-form-urlencoded -
multipart/form-data
提示
当我们在函数中访问request.data属性时,Django REST Framework 会检查传入请求中的Content-Type头部的值,并确定适当的解析器来解析请求内容。如果我们使用之前解释的默认值,Django REST Framework 将能够解析之前列出的内容类型。然而,请求在Content-Type头部指定适当的值是极其重要的。
我们必须在函数中移除对rest_framework.parsers.JSONParser类的使用,以便能够使用所有配置的解析器,并停止使用仅适用于 JSON 的解析器。当request.method等于'POST'时,game_list函数执行以下两行代码:
game_data = JSONParser().parse(request)
game_serializer = GameSerializer(data=game_data)
我们将移除使用JSONParser的第一行,并将request.data作为GameSerializer的数据参数传递。以下行将替换之前的行:
game_serializer = GameSerializer(data=request.data)
当request.method等于'PUT'时,game_detail函数执行以下两行代码:
game_data = JSONParser().parse(request)
game_serializer = GameSerializer(game, data=game_data)
我们将对game_list函数中的代码进行相同的编辑。我们将移除使用JSONParser的第一行,并将request.data作为数据参数传递给GameSerializer。以下行将替换之前的行:
game_serializer = GameSerializer(game, data=request.data)
默认情况下,DEFAULT_RENDERER_CLASSES的值是以下类元组:
(
'rest_framework.renderers.JSONRenderer',
'rest_framework.renderers.BrowsableAPIRenderer',
)
当我们使用装饰器时,API 将能够通过适当的渲染器在响应中渲染以下内容类型,当与rest_framework.response.Response对象一起工作时:
-
application/json -
text/html
默认情况下,DEFAULT_CONTENT_NEGOTIATION_CLASS的值是rest_framework.negotiation.DefaultContentNegotiation类。当我们使用装饰器时,API 将使用此内容协商类根据传入的请求选择适当的渲染器。这样,当请求指定它将接受text/html时,内容协商类选择rest_framework.renderers.BrowsableAPIRenderer来渲染响应并生成text/html而不是application/json。
我们必须在函数中替换JSONResponse和HttpResponse类的使用,使用rest_framework.response.Response类。Response类使用之前解释的内容协商功能,将接收到的数据渲染到适当的内容类型,并将其返回给客户端。
现在,前往gamesapi/games文件夹并打开views.py文件。将此文件中的代码替换为以下代码,该代码移除了JSONResponse类,并使用@api_view装饰器为函数和rest_framework.response.Response类。修改的行已突出显示。示例代码文件包含在restful_python_chapter_02_02文件夹中:
from rest_framework.parsers import JSONParser
from rest_framework import status
from rest_framework.decorators import api_view
from rest_framework.response import Response
from games.models import Game
from games.serializers import GameSerializer
@api_view(['GET', 'POST'])
def game_list(request):
if request.method == 'GET':
games = Game.objects.all()
games_serializer = GameSerializer(games, many=True)
return Response(games_serializer.data)
elif request.method == 'POST':
game_serializer = GameSerializer(data=request.data)
if game_serializer.is_valid():
game_serializer.save()
return Response(game_serializer.data, status=status.HTTP_201_CREATED)
return Response(game_serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@api_view(['GET', 'PUT', 'POST'])
def game_detail(request, pk):
try:
game = Game.objects.get(pk=pk)
except Game.DoesNotExist:
return Response(status=status.HTTP_404_NOT_FOUND)
if request.method == 'GET':
game_serializer = GameSerializer(game)
return Response(game_serializer.data)
elif request.method == 'PUT':
game_serializer = GameSerializer(game, data=request.data)
if game_serializer.is_valid():
game_serializer.save()
return Response(game_serializer.data)
return Response(game_serializer.errors, status=status.HTTP_400_BAD_REQUEST)
elif request.method == 'DELETE':
game.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
保存上述更改后,运行以下命令:
http OPTIONS :8000/games/
以下是对应的curl命令:
curl -iX OPTIONS :8000/games/
之前的命令将组合并发送以下 HTTP 请求:OPTIONS http://localhost:8000/games/。请求将匹配并运行views.game_list函数,即games/views.py文件中声明的game_list函数。我们为此函数添加了@api_view装饰器,因此它现在能够确定支持的 HTTP 动词、解析和渲染能力。以下行显示了输出:
HTTP/1.0 200 OK
Allow: GET, POST, OPTIONS
Content-Type: application/json
Date: Thu, 09 Jun 2016 20:24:31 GMT
Server: WSGIServer/0.2 CPython/3.5.1
Vary: Accept, Cookie
X-Frame-Options: SAMEORIGIN
{
"description": "",
"name": "Game List",
"parses": [
"application/json",
"application/x-www-form-urlencoded",
"multipart/form-data"
],
"renders": [
"application/json",
"text/html"
]
}
响应头包含一个Allow键,其值为资源集合支持的 HTTP 动词的逗号分隔列表:GET, POST, OPTIONS。由于我们的请求没有指定允许的内容类型,函数以默认的application/json内容类型渲染了响应。响应体指定了资源集合解析和渲染的Content-type。
运行以下命令以使用OPTIONS动词为游戏资源组合并发送 HTTP 请求。别忘了将3替换为配置中现有游戏的主键值。
http OPTIONS :8000/games/3/
以下是对应的 curl 命令:
curl -iX OPTIONS :8000/games/3/
之前的命令将编写并发送以下 HTTP 请求:OPTIONS http://localhost:8000/games/3/。该请求将匹配并运行views.game_detail函数,即games/views.py文件中声明的game_detail函数。我们还为此函数添加了@api_view装饰器,因此它能够确定支持的 HTTP 动词、解析和渲染能力。以下行显示了输出:
HTTP/1.0 200 OK
Allow: GET, POST, OPTIONS, PUT
Content-Type: application/json
Date: Thu, 09 Jun 2016 21:35:58 GMT
Server: WSGIServer/0.2 CPython/3.5.1
Vary: Accept, Cookie
X-Frame-Options: SAMEORIGIN
{
"description": "",
"name": "Game Detail",
"parses": [
"application/json",
"application/x-www-form-urlencoded",
"multipart/form-data"
],
"renders": [
"application/json",
"text/html"
]
}
响应头包括一个Allow键,其值为资源支持的 HTTP 动词的逗号分隔列表:GET, POST, OPTIONS, PUT。响应体指定了资源解析和渲染的内容类型,与之前收到的OPTIONS请求中应用于资源集合(即游戏集合)的相同内容。
在第一章 使用 Django 开发 RESTful API 中,当我们编写并发送 POST 和 PUT 命令时,我们必须使用-H "Content-Type: application/json"选项来告诉 curl 将-d选项之后指定的数据作为application/json发送,而不是默认的application/x-www-form-urlencoded。现在,除了application/json之外,我们的 API 还能够解析POST和PUT请求中指定的application/x-www-form-urlencoded和multipart/form-data数据。因此,我们可以编写并发送一个 POST 命令,将数据作为application/x-www-form-urlencoded发送,并且对 API 所做的更改已经生效。
我们将编写并发送一个 HTTP 请求来创建一个新的游戏。在这种情况下,我们将使用 HTTPie 的-f 选项,该选项将命令行中的数据项序列化为表单字段,并将Content-Type头键设置为application/x-www-form-urlencoded值:
http -f POST :8000/games/ name='Toy Story 4' game_category='3D RPG'
played=false release_date='2016-05-18T03:02:00.776594Z'
以下是对应的 curl 命令。请注意,我们不使用-H选项,curl 将以默认的application/x-www-form-urlencoded发送数据:
curl -iX POST -d '{"name":"Toy Story 4", "game_category":"3D RPG", "played":
"false", "release_date": "2016-05-18T03:02:00.776594Z"}' :8000/games/
之前的命令将编写并发送以下 HTTP 请求:POST http://localhost:8000/games/,并将Content-Type头键设置为application/x-www-form-urlencoded值,以及以下数据:
name=Toy+Story+4&game_category=3D+RPG&played=false&release_date=2016-05-18T03%3A02%3A00.776594Z
请求指定/games/,因此,它将匹配'^games/$'并运行views.game_list函数,即games/views.py文件中声明的更新后的game_detail函数。由于请求的 HTTP 动词是POST,request.method属性等于'POST',因此,该函数将执行创建GameSerializer实例的代码,并将request.data作为其创建的数据参数。rest_framework.parsers.FormParser类将解析请求中接收到的数据,代码创建一个新的Game对象,如果数据有效,则将其保存。如果新的Game对象成功持久化到数据库中,该函数返回HTTP 201 Created状态码,并在响应体中将最近持久化的Game对象序列化为 JSON。以下行显示了 HTTP 请求的示例响应,其中包含 JSON 响应中的新Game对象:
HTTP/1.0 201 Created
Allow: OPTIONS, POST, GET
Content-Type: application/json
Date: Fri, 10 Jun 2016 20:38:40 GMT
Server: WSGIServer/0.2 CPython/3.5.1
Vary: Accept, Cookie
X-Frame-Options: SAMEORIGIN
{
"game_category": "3D RPG",
"id": 20,
"name": "Toy Story 4",
"played": false,
"release_date": "2016-05-18T03:02:00.776594Z"
}
我们可以在对代码进行更改后运行以下命令,以查看当我们使用不被支持的 HTTP 动词组成并发送 HTTP 请求时会发生什么:
http PUT :8000/games/
以下是对应的curl命令:
curl -iX PUT :8000/games/
之前的命令将组成并发送以下 HTTP 请求:PUT http://localhost:8000/games/。请求将匹配并尝试运行views.game_list函数,即games/views.py文件中声明的game_list函数。我们添加到这个函数的@api_view装饰器不包括'PUT'在允许的 HTTP 动词的字符串列表中,因此,默认行为返回405 Method Not Allowed状态码。以下行显示了输出以及之前请求的响应。一个 JSON 内容提供了一个detail键,其字符串值指示PUT方法不被允许:
HTTP/1.0 405 Method Not Allowed
Allow: GET, OPTIONS, POST
Content-Type: application/json
Date: Sat, 11 Jun 2016 00:49:30 GMT
Server: WSGIServer/0.2 CPython/3.5.1
Vary: Accept, Cookie
X-Frame-Options: SAMEORIGIN
{
"detail": "Method "PUT" not allowed."
}
浏览 API
通过最近的编辑,我们使我们的 API 能够使用 Django REST Framework 中配置的默认内容渲染器,因此,我们的 API 能够渲染text/html内容。我们可以利用可浏览的 API,这是 Django REST Framework 中包含的一个特性,它会在请求指定请求头中的Content-type键值为text/html时,为每个资源生成人类友好的 HTML 输出。
每当我们在一个网络浏览器中输入 API 资源的 URL 时,浏览器将需要一个 HTML 响应,因此,Django REST Framework 将提供一个使用 Bootstrap 构建的 HTML 响应(getbootstrap.com)。此响应将包括一个显示资源内容的 JSON 部分,执行不同请求的按钮,以及提交数据到资源的表单。正如 Django REST Framework 中的所有内容一样,我们可以自定义用于生成可浏览 API 的模板和主题。
打开一个网络浏览器并输入http://localhost:8000/games/。可浏览的 API 将编写并发送一个对/games/的GET请求,并将显示其执行结果,即头部信息和 JSON 游戏列表。以下截图显示了在网页浏览器中输入 URL 后的渲染网页,其中包含资源描述-游戏列表:

提示
如果你决定在另一台运行在局域网内的计算机或设备上的网络浏览器中浏览 API,请记住你必须使用开发计算机分配的 IP 地址而不是localhost。例如,如果计算机分配的 IPv4 IP 地址是192.168.1.106,那么你应该使用http://192.168.1.106:8000/games/而不是http://localhost:8000/games/。当然,你也可以使用主机名而不是 IP 地址。
可浏览的 API 使用有关资源允许的方法的信息,为我们提供按钮来运行这些方法。在资源描述的右侧,可浏览的 API 显示了一个OPTIONS按钮和一个GET下拉按钮。OPTIONS按钮允许我们向/games/发送一个OPTIONS请求,即当前资源。GET下拉按钮允许我们再次向/games/发送一个GET请求。如果我们点击或轻触向下箭头,我们可以选择json选项,可浏览的 API 将显示GET请求的原始 JSON 结果,而不显示头部信息。
在渲染的网页底部,可浏览的 API 为我们提供了一些控制,以生成对/games/的POST请求。媒体类型下拉菜单允许我们在为我们的 API 配置的解析器之间进行选择:
-
application/json -
application/x-www-form-urlencoded -
multipart/form-data
内容文本框允许我们指定要发送到POST请求的数据,格式与媒体类型下拉菜单中指定的一致。在媒体类型下拉菜单中选择application/json,并在内容文本框中输入以下 JSON 内容:
{
"name": "Chuzzle 2",
"release_date": "2016-05-18T03:02:00.776594Z",
"game_category": "2D mobile",
"played": false
}
点击或轻触POST。可浏览的 API 将编写并发送一个包含之前指定数据的POST请求到/games/,我们将在网络浏览器中看到调用结果。
以下截图显示了一个网页浏览器在响应中显示了 HTTP 状态码201 Created,以及之前解释过的下拉菜单和带有POST按钮的文本框,允许我们继续编写并发送对/games/的POST请求:

现在,输入现有游戏资源的 URL,例如http://localhost:8000/games/2/。确保将 2 替换为之前渲染的游戏列表中现有游戏的 id 或主键。可浏览的 API 将编写并发送一个对/games/2/的GET请求,并将显示其执行结果,即游戏的头部和 JSON 数据。
以下截图显示了在网页浏览器中输入 URL 后渲染的网页,其中包含资源描述-游戏详情:

提示
可浏览的 API 功能使我们能够轻松检查 API 的工作方式,并向任何可以访问我们局域网的浏览器发送不同方法的 HTTP 请求。我们将利用可浏览 API 中包含的附加功能,例如 HTML 表单,它允许我们轻松创建新资源,在我们使用 Python 和 Django REST 框架构建新的 RESTful API 之后。
设计一个与复杂的 PostgreSQL 数据库交互的 RESTful API
到目前为止,我们的 RESTful API 已经在单个数据库表上执行了 CRUD 操作。现在,我们想要使用 Django REST 框架创建一个更复杂的 RESTful API,以与一个复杂的数据库模型交互,该模型必须允许我们为分组到游戏类别的已玩游戏注册玩家分数。在我们的上一个 RESTful API 中,我们使用一个字符串字段来指定游戏的类别。在这种情况下,我们希望能够轻松检索属于特定游戏类别的所有游戏,因此,我们将有一个游戏和游戏类别之间的关系。
我们应该能够在不同的相关资源和资源集合上执行 CRUD 操作。以下列表列出了我们将使用以在 Django REST 框架中表示它们的资源和模型名称:
-
游戏类别(
GameCategory模型) -
游戏(
Game模型) -
玩家(
Player模型) -
玩家分数(
PlayerScore模型)
游戏类别(GameCategory)只需要一个名称,而对于游戏(Game),我们需要以下数据:
-
一个指向游戏类别(
GameCategory)的外键 -
一个名称
-
发布日期
-
一个布尔值,表示游戏是否至少被玩家玩过一次
-
一个时间戳,表示游戏被插入数据库的日期和时间
对于玩家(Player),我们需要以下数据:
-
一个性别值
-
一个名称
-
一个时间戳,表示玩家被插入数据库的日期和时间
对于玩家获得的分数(PlayerScore),我们需要以下数据:
-
一个指向玩家(
Player)的外键 -
一个指向游戏(
Game)的外键 -
一个分数值
-
玩家获得分数值的日期
提示
我们将利用所有资源和它们之间的关系来分析 Django REST 框架在处理相关资源时为我们提供的不同选项。我们不会构建使用相同配置来显示相关资源的 API,而是将使用不同的配置,这将允许我们根据我们正在开发的 API 的特定要求选择最合适的选项。
理解每个 HTTP 方法执行的任务
以下表格显示了我们的新 API 必须支持的 HTTP 动词、作用域和语义。每个方法由一个 HTTP 动词和一个作用域组成,并且所有方法对所有资源和集合都有明确的含义。
| HTTP 动词 | 作用域 | 语义 |
|---|---|---|
GET |
游戏类别集合 | 获取集合中所有存储的游戏类别,按名称升序排序。每个游戏类别必须包括属于该类别的每个游戏资源的 URL 列表。 |
GET |
游戏类别 | 获取单个游戏类别。游戏类别必须包括属于该类别的每个游戏资源的 URL 列表。 |
POST |
游戏类别集合 | 在集合中创建一个新的游戏类别。 |
PUT |
游戏类别 | 更新现有的游戏类别。 |
PATCH |
游戏类别 | 更新现有游戏类别的多个字段。 |
DELETE |
游戏类别 | 删除现有的游戏类别。 |
GET |
游戏集合 | 获取集合中所有存储的游戏,按名称升序排序。每个游戏必须包括其游戏类别描述。 |
GET |
游戏 | 获取单个游戏。游戏必须包括其游戏类别描述。 |
POST |
游戏集合 | 在集合中创建一个新的游戏。 |
PUT |
游戏类别 | 更新现有游戏。 |
PATCH |
游戏类别 | 更新现有游戏的多个字段。 |
DELETE |
游戏类别 | 删除现有的游戏。 |
GET |
玩家集合 | 获取集合中所有存储的玩家,按名称升序排序。每个玩家必须包括按分数降序排序的已注册分数列表。列表必须包括玩家获得的分数及其相关游戏的详细信息。 |
GET |
玩家 | 获取单个玩家。玩家必须包括按分数降序排序的已注册分数列表。列表必须包括玩家获得的分数及其相关游戏的详细信息。 |
POST |
玩家集合 | 在集合中创建一个新的玩家。 |
PUT |
玩家 | 更新现有的玩家。 |
PATCH |
玩家 | 更新现有玩家的多个字段。 |
DELETE |
玩家 | 删除现有的玩家。 |
GET |
分数集合 | 获取集合中所有存储的分数,按分数降序排序。每个分数必须包括获得分数的玩家姓名和游戏名称。 |
GET |
分数 | 获取单个分数。该分数必须包括获得分数的玩家姓名和游戏名称。 |
POST |
分数集合 | 在集合中创建一个新的分数。该分数必须与现有玩家和现有游戏相关。 |
PUT |
分数 | 更新现有的分数。 |
PATCH |
分数 | 更新现有分数的多个字段。 |
DELETE |
分数 | 删除现有的分数。 |
我们希望我们的 API 能够更新现有资源的单个字段,因此我们将提供一个 PATCH 方法的实现。PUT 方法旨在替换整个资源,而 PATCH 方法旨在对现有资源应用增量。此外,我们的 RESTful API 必须支持所有资源及其集合的 OPTIONS 方法。
我们不想花费时间选择和配置最合适的 ORM,就像我们在之前的 API 中看到的那样;我们只想尽快完成 RESTful API 以开始与之交互。我们将使用 Django REST Framework 中包含的所有功能和可重用元素,以简化我们的 API 构建。我们将使用 PostgreSQL 数据库。然而,如果你不想花费时间安装 PostgreSQL,你可以跳过我们在 Django REST Framework ORM 配置中做出的更改,并继续使用默认的 SQLite 数据库。
在前面的表中,我们有许多方法和作用域。以下列表列出了表中提到的每个作用域的 URI,其中 {id} 需要替换为资源的数字 ID 或主键:
-
游戏类别集合:
/game-categories/ -
游戏类别:
/game-category/{id}/ -
游戏集合:
/games/ -
游戏:
/game/{id}/ -
玩家集合:
/players/ -
玩家:
/player/{id}/ -
分数集合:
/player-scores/ -
分数:
/player-score/{id}/
让我们假设 http://localhost:8000/ 是运行在 Django 开发服务器上的 API 的 URL。我们必须使用以下 HTTP 动词 (GET) 和请求 URL (http://localhost:8000/game-categories/) 来组合和发送一个 HTTP 请求,以检索存储在集合中的所有游戏类别:
GET http://localhost:8000/game-categories/
声明与模型的关系
确保你已退出 Django 的开发服务器。记住,你只需在运行开发服务器的终端或命令提示符窗口中按 Ctrl + C 即可。现在,我们将创建我们将要用来表示和持久化游戏类别、游戏、玩家和分数及其关系的模型。打开 games/models.py 文件,并用以下代码替换其内容。声明与其他模型相关字段的行在代码列表中突出显示。示例代码文件包含在 restful_python_chapter_02_03 文件夹中。
from django.db import models
class GameCategory(models.Model):
name = models.CharField(max_length=200)
class Meta:
ordering = ('name',)
def __str__(self):
return self.name
class Game(models.Model):
created = models.DateTimeField(auto_now_add=True)
name = models.CharField(max_length=200)
game_category = models.ForeignKey(
GameCategory,
related_name='games',
on_delete=models.CASCADE)
release_date = models.DateTimeField()
played = models.BooleanField(default=False)
class Meta:
ordering = ('name',)
def __str__(self):
return self.name
class Player(models.Model):
MALE = 'M'
FEMALE = 'F'
GENDER_CHOICES = (
(MALE, 'Male'),
(FEMALE, 'Female'),
)
created = models.DateTimeField(auto_now_add=True)
name = models.CharField(max_length=50, blank=False, default='')
gender = models.CharField(
max_length=2,
choices=GENDER_CHOICES,
default=MALE,
)
class Meta:
ordering = ('name',)
def __str__(self):
return self.name
class PlayerScore(models.Model):
player = models.ForeignKey(
Player,
related_name='scores',
on_delete=models.CASCADE)
game = models.ForeignKey(
Game,
on_delete=models.CASCADE)
score = models.IntegerField()
score_date = models.DateTimeField()
class Meta:
# Order by score descending
ordering = ('-score',)
上述代码声明了以下四个模型,具体是四个作为 django.db.models.Model 类子类的类:
-
GameCategory -
Game -
Player -
PlayerScore
Django 在创建与每个模型相关的数据库表时自动添加一个名为 id 的自增整数主键列。我们指定了许多属性的字段类型、最大长度和默认值。每个类声明一个 Meta 内部类,该类声明一个排序属性。在 PlayerScore 类中声明的 Meta 内部类指定 '-score' 作为 ordering 元组的值,以字段名前缀的形式使用连字符,并按 score 降序排序,而不是默认的升序排序。
GameCategory、Game 和 Player 类声明了 __str__ 方法,该方法返回 name 属性的内容,为这些模型中的每个提供名称或标题。因此,Django 在需要为模型提供人类可读表示时将调用此方法。
Game 模型使用以下行声明了 game_category 字段:
game_category = models.ForeignKey(
GameCategory,
related_name='games',
on_delete=models.CASCADE)
前一行使用 django.db.models.ForeignKey 类为 GameCategory 模型提供多对一关系。为相关名参数指定的 'games' 值创建了一个从 GameCategory 模型到 Game 模型的反向关系。此值表示从相关的 GameCategory 对象返回到 Game 对象所使用的名称。现在,我们将能够访问属于特定游戏类别的所有游戏。每当删除一个游戏类别时,我们希望属于此类别的所有游戏也被删除,因此,我们为 on_delete 参数指定了 models.CASCADE 值。
PlayerScore 模型使用以下行声明了 player 字段:
player = models.ForeignKey(
Player,
related_name='scores',
on_delete=models.CASCADE)
前一行使用 django.db.models.ForeignKey 类为 Player 模型提供多对一关系。为相关名参数指定的 'scores' 值创建了一个从 Player 模型到 PlayerScore 模型的反向关系。此值表示从相关的 Player 对象返回到 PlayerScore 对象所使用的名称。现在,我们将能够访问特定玩家归档的所有分数。每当删除一个玩家时,我们希望此玩家所获得的所有分数也被删除,因此,我们为 on_delete 参数指定了 models.CASCADE 值。
PlayerScore 模型使用以下行声明了 game 字段:
game = models.ForeignKey(
Game,
on_delete=models.CASCADE)
前一行使用 django.db.models.ForeignKey 类为 Game 模型提供多对一关系。在这种情况下,我们不创建反向关系,因为我们不需要它。因此,我们不指定相关名参数的值。每当删除一个游戏时,我们希望删除此游戏的所有已注册分数,因此,我们为 on_delete 参数指定了 models.CASCADE 值。
如果您为处理此示例创建了新的虚拟环境或下载了本书的示例代码,您不需要删除任何现有数据库。然而,如果您正在修改我们之前的 API 示例中的代码,您必须删除 gamesapi/db.sqlite3 文件和 games/migrations 文件夹。
然后,我们需要为最近编写的新的模型创建初始迁移。我们只需运行以下 Python 脚本,我们还将首次同步数据库。正如我们从之前的示例 API 中学到的那样,默认情况下,Django 使用 SQLite 数据库。在这个例子中,我们将使用 PostgreSQL 数据库。但是,如果您想使用 SQLite,可以跳过与 PostgreSQL 相关的步骤,包括在 Django 中的配置,并跳转到迁移生成命令。
如果您在计算机或开发服务器上尚未运行 PostgreSQL 数据库,您将需要下载并安装它。您可以从其网页-www.postgresql.org下载并安装此数据库管理系统。如果您在 macOS 上工作,Postgres.app提供了一个在操作系统上安装和使用 PostgreSQL 的简单方法-postgresapp.com。
小贴士
您必须确保 PostgreSQL bin 文件夹包含在PATH环境变量中。您应该能够从当前的终端或命令提示符中执行psql命令行实用程序。如果文件夹未包含在 PATH 中,当尝试安装psycopg2包时,您将收到一个错误,指示找不到pg_config文件。此外,您将不得不使用后续步骤中我们将使用的每个 PostgreSQL 命令行工具的完整路径。
我们将使用 PostgreSQL 命令行工具创建一个名为games的新数据库。如果您已经有一个同名 PostgreSQL 数据库,请确保在所有命令和配置中使用另一个名称。您可以使用任何 PostgreSQL GUI 工具执行相同的任务。如果您在 Linux 上开发,必须以postgres用户身份运行命令。在 macOS 或 Windows 上运行以下命令以创建一个名为games的新数据库。请注意,该命令不会产生任何输出:
createdb games
在 Linux 上,运行以下命令以使用postgres用户:
sudo -u postgres createdb games
现在,我们将使用psql命令行工具运行一些 SQL 语句来创建我们将用于 Django 的特定用户,并为其分配必要的角色。在 macOS 或 Windows 上,运行以下命令以启动psql:
psql
在 macOS 上,如果您发现之前的命令不起作用,可能需要运行以下命令以使用postgres启动 psql,这取决于您安装 PostgreSQL 的方式:
sudo -u postgres psql
在 Linux 上,运行以下命令以使用postgres用户。
sudo -u psql
然后,运行以下 SQL 语句,最后输入\q退出 psql 命令行工具。将user_name替换为您在新的数据库中希望使用的用户名,将密码替换为您选择的密码。我们将使用 Django 配置中的用户名和密码。如果您已经在 PostgreSQL 中与特定用户一起工作,并且已经为该用户授予了数据库权限,则不需要运行这些步骤:
CREATE ROLE user_name WITH LOGIN PASSWORD 'password';
GRANT ALL PRIVILEGES ON DATABASE games TO user_name;
ALTER USER user_name CREATEDB;
\q
默认的 SQLite 数据库引擎和数据库文件名在gamesapi/settings.pyPython 文件中指定。如果您决定使用 PostgreSQL 而不是 SQLite 进行此示例,请将DATABASES字典的声明替换为以下行。嵌套字典将名为default的数据库映射到django.db.backends.postgresql数据库引擎、所需的数据库名称及其设置。在这种情况下,我们将创建一个名为games的数据库。请确保在'NAME'键的值中指定所需的数据库名称,并根据您的 PostgreSQL 配置配置用户、密码、主机和端口。如果您遵循了之前的步骤,请使用这些步骤中指定的设置:
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
# Replace games with your desired database name
'NAME': 'games',
# Replace username with your desired user name
'USER': 'user_name',
# Replace password with your desired password
'PASSWORD': 'password',
# Replace 127.0.0.1 with the PostgreSQL host
'HOST': '127.0.0.1',
# Replace 5432 with the PostgreSQL configured port
# in case you aren't using the default port
'PORT': '5432',
}
}
如果您决定使用 PostgreSQL,在做出上述更改后,有必要安装 Psycopg 2 包(psycopg2)。此包是 Python-PostgreSQL 数据库适配器,Django 使用它来与 PostgreSQL 数据库交互。
在 macOS 安装中,我们必须确保 PostgreSQL 的 bin 文件夹包含在PATH环境变量中。例如,如果 bin 文件夹的路径是/Applications/Postgres.app/Contents/Versions/latest/bin,我们必须执行以下命令将此文件夹添加到PATH环境变量中:
export PATH=$PATH:/Applications/Postgres.app/Contents/Versions/latest/bin
一旦我们确认 PostgreSQL 的bin文件夹已包含在 PATH 环境变量中,我们只需运行以下命令即可安装此包:
pip install psycopg2
输出的最后一行将指示psycopg2包已成功安装:
Collecting psycopg2
Installing collected packages: psycopg2
Running setup.py install for psycopg2
Successfully installed psycopg2-2.6.2
现在,运行以下 Python 脚本以生成允许我们首次同步数据库的迁移:
python manage.py makemigrations games
以下行显示了运行上一条命令后生成的输出:
Migrations for 'games':
0001_initial.py:
- Create model Game
- Create model GameCategory
- Create model Player
- Create model PlayerScore
- Add field game_category to game
输出表明gamesapi/games/migrations/0001_initial.py文件包含了创建Game、GameCategory、Player和PlayerScore模型的代码。以下行显示了由 Django 自动生成的此文件的代码。示例的代码文件包含在restful_python_chapter_02_03文件夹中:
# -*- coding: utf-8 -*-
# Generated by Django 1.9.7 on 2016-06-17 20:39
from __future__ import unicode_literals
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Game',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', models.DateTimeField(auto_now_add=True)),
('name', models.CharField(max_length=200)),
('release_date', models.DateTimeField()),
('played', models.BooleanField(default=False)),
],
options={
'ordering': ('name',),
},
),
migrations.CreateModel(
name='GameCategory',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=200)),
],
options={
'ordering': ('name',),
},
),
migrations.CreateModel(
name='Player',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', models.DateTimeField(auto_now_add=True)),
('name', models.CharField(default='', max_length=50)),
('gender', models.CharField(choices=[('M', 'Male'), ('F', 'Female')], default='M', max_length=2)),
],
options={
'ordering': ('name',),
},
),
migrations.CreateModel(
name='PlayerScore',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('score', models.IntegerField()),
('score_date', models.DateTimeField()),
('game', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='games.Game')),
('player', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scores', to='games.Player')),
],
options={
'ordering': ('-score',),
},
),
migrations.AddField(
model_name='game',
name='game_category',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='games', to='games.GameCategory'),
),
]
上述代码定义了一个名为Migration的django.db.migrations.Migration类的子类,该类定义了一个包含许多migrations.CreateModel的operations列表。每个migrations.CreateModel将为每个相关模型创建一个表。请注意,Django 已经为每个模型自动添加了一个id字段。operations按列表中出现的顺序执行。该代码创建了Game、GameCategory、Player、PlayerScore表,并最终将game_category字段添加到Game中,该字段具有指向GameCategory的外键,因为它在创建GameCategory模型之前创建了Game模型。当创建模型时,它为PlayerScore创建了外键:
现在,运行以下 Python 脚本以应用所有生成的迁移。
python manage.py migrate
以下行显示了运行上一个命令后的输出:
Operations to perform:
Apply all migrations: sessions, contenttypes, games, admin, auth
Running migrations:
Rendering model states... DONE
Applying contenttypes.0001_initial... OK
Applying auth.0001_initial... OK
Applying admin.0001_initial... OK
Applying admin.0002_logentry_remove_auto_add... OK
Applying contenttypes.0002_remove_content_type_name... OK
Applying auth.0002_alter_permission_name_max_length... OK
Applying auth.0003_alter_user_email_max_length... OK
Applying auth.0004_alter_user_username_opts... OK
Applying auth.0005_alter_user_last_login_null... OK
Applying auth.0006_require_contenttypes_0002... OK
Applying auth.0007_alter_validators_add_error_messages... OK
Applying games.0001_initial... OK
Applying sessions.0001_initial... OK
在我们运行上一个命令后,我们可以使用 PostgreSQL 命令行或任何允许我们轻松检查 PostreSQL 数据库内容的其他应用程序来检查 Django 生成的表。如果你正在使用 SQLite,我们已经在第一章使用 Django 开发 RESTful API中学习了如何检查表。
运行以下命令以列出生成的表:
psql --username=user_name --dbname=games --command="\dt"
以下行显示了所有生成的表名的输出:
List of relations
Schema | Name | Type | Owner
--------+----------------------------+-------+-----------
public | auth_group | table | user_name
public | auth_group_permissions | table | user_name
public | auth_permission | table | user_name
public | auth_user | table | user_name
public | auth_user_groups | table | user_name
public | auth_user_user_permissions | table | user_name
public | django_admin_log | table | user_name
public | django_content_type | table | user_name
public | django_migrations | table | user_name
public | django_session | table | user_name
public | games_game | table | user_name
public | games_gamecategory | table | user_name
public | games_player | table | user_name
public | games_playerscore | table | user_name
(14 rows)
如前一个示例所示,Django 使用games_前缀为与games应用相关的以下四个表名。Django 的集成 ORM 根据我们模型中包含的信息生成了这些表和外键:
-
games_game: 持久化Game模型 -
games_gamecategory: 持久化GameCategory模型 -
games_player: 持久化Player模型 -
games_playerscore: 持久化PlayerScore模型
以下命令将在我们向 RESTful API 发送 HTTP 请求并执行对四个表的 CRUD 操作后允许您检查四个表的内容。这些命令假设您正在同一台运行命令的计算机上运行 PostgreSQL。
psql --username=user_name --dbname=games --command="SELECT * FROM games_gamecategory;"
psql --username=user_name --dbname=games --command="SELECT * FROM games_game;"
psql --username=user_name --dbname=games --command="SELECT * FROM games_player;"
psql --username=user_name --dbname=games --command="SELECT * FROM games_playerscore;"
提示
除了使用 PostgreSQL 命令行工具外,您还可以使用 GUI 工具来检查 PostgreSQL 数据库的内容。您还可以使用您最喜欢的 IDE 中包含的数据库工具来检查 SQLite 数据库的内容。
Django 生成了一些额外的表,这些表是它支持 Web 框架和我们将要使用的认证功能所必需的。
使用关系和超链接管理序列化和反序列化
我们新的 RESTful Web API 必须能够将GameCategory、Game、Player和PlayerScore实例序列化和反序列化为 JSON 表示。在这种情况下,我们还需要在创建序列化器类以管理 JSON 序列化和反序列化时特别注意不同模型之间的关系。
在我们之前版本的 API 中,我们创建了一个rest_framework.serializers.ModelSerializer类的子类,以便更容易生成序列化器并减少样板代码。在这种情况下,我们也将声明一个继承自ModelSerializer的类,但其他类将继承自rest_framework.serializers.HyperlinkedModelSerializer类。
HyperlinkedModelSerializer是一种ModelSerializer类型,它使用超链接关系而不是主键关系,因此它使用超链接而不是主键值来表示与其他模型实例的关系。此外,HyperlinkedModelSerializer生成一个名为url的字段,其值为资源的 URL。正如在ModelSerializer的案例中看到的那样,HyperlinkedModelSerializer类为create和update方法提供了默认实现。
现在,转到gamesapi/games文件夹,并打开serializers.py文件。用以下代码替换此文件中的代码,以声明所需的导入和GameCategorySerializer类。我们稍后将在该文件中添加更多类。示例代码文件包含在restful_python_chapter_02_03文件夹中:
from rest_framework import serializers
from games.models import GameCategory
from games.models import Game
from games.models import Player
from games.models import PlayerScore
import games.views
class GameCategorySerializer(serializers.HyperlinkedModelSerializer):
games = serializers.HyperlinkedRelatedField(
many=True,
read_only=True,
view_name='game-detail')
class Meta:
model = GameCategory
fields = (
'url',
'pk',
'name',
'games')
GameCategorySerializer类是HyperlinkedModelSerializer类的子类。GameCategorySerializer类声明了一个games属性,它是一个serializers.HyperlinkedRelatedField实例,many和read_only都设置为True,因为它是一对多关系且只读。我们使用在创建Game模型中的game_category字段时指定的related_name字符串值'games'。这样,games字段将为我们提供指向属于游戏类别的每个游戏的超链接数组。view_name的值是'game-detail',因为我们希望可浏览的 API 功能使用游戏详情视图来渲染超链接,当用户点击或轻触时。
GameCategorySerializer类声明了一个Meta内部类,该类声明了两个属性:model和fields。model属性指定了与序列化器相关的模型,即GameCategory类。fields属性指定了一个字符串元组,其值表示我们想要包含在序列化中的相关模型的字段名称。我们希望包含主键和 URL,因此代码指定了元组的成员'pk'和'url'。在这种情况下,没有必要重写create或update方法,因为通用行为将足够。HyperlinkedModelSerializer超类提供了这两个方法的实现。
现在,将以下代码添加到serializers.py文件中,以声明GameSerializer类。示例代码文件包含在restful_python_chapter_02_03文件夹中:
class GameSerializer(serializers.HyperlinkedModelSerializer):
# We want to display the game cagory's name instead of the id
game_category = serializers.SlugRelatedField(queryset=GameCategory.objects.all(), slug_field='name')
class Meta:
model = Game
fields = (
'url',
'game_category',
'name',
'release_date',
'played')
GameSerializer 类是 HyperlinkedModelSerializer 类的子类。GameSerializer 类声明了一个 game_category 属性,它是一个 serializers.SlugRelatedField 的实例,其 queryset 参数设置为 GameCategory.objects.all(),其 slug_field 参数设置为 'name'。SlugRelatedField 是一个读写字段,它通过唯一的 slug 属性(即描述)表示关系的目标。我们已在 Game 模型中创建了一个 game_category 字段,并希望将游戏类别的名称作为相关 GameCategory 的描述(slug 字段)显示。因此,我们指定了 'name' 作为 slug_field。如果需要在可浏览的 API 中的表单中显示相关游戏类别的可能选项,Django 将使用在 queryset 参数中指定的表达式检索所有可能的实例,并显示它们指定的 slug 字段。
GameCategorySerializer 类声明了一个 Meta 内部类,该类声明了两个属性:model 和 fields。model 属性指定了与序列器相关的模型,即 Game 类。fields 属性指定了一个字符串元组,其值表示我们希望在序列化相关模型时包含的字段名称。我们只想包含 URL,因此代码将 'url' 作为元组的一个成员。game_category 字段将指定相关 GameCategory 的 name 字段。
现在,将以下代码添加到 serializers.py 文件中,以声明 ScoreSerializer 类。示例代码文件包含在 restful_python_chapter_02_03 文件夹中:
class ScoreSerializer(serializers.HyperlinkedModelSerializer):
# We want to display all the details for the game
game = GameSerializer()
# We don't include the player because it will be nested in the player
class Meta:
model = PlayerScore
fields = (
'url',
'pk',
'score',
'score_date',
'game',
)
ScoreSerializer 类是 HyperlinkedModelSerializer 类的子类。我们将使用 ScoreSerializer 类来序列化与 Player 相关的 PlayerScore 实例,即在我们序列化 Player 时显示特定玩家的所有分数。我们希望显示相关 Game 的所有详细信息,但不包括相关 Player,因为 Player 将使用此 ScoreSerializer 序列器。
ScoreSerializer 类声明了一个 game 属性,它是一个之前编写的 GameSerializer 类的实例。我们在 PlayerScore 模型中创建了一个 game 字段,作为 models.ForeignKey 实例,并希望序列化与 GameSerializer 类中编写的游戏相同的数据。
ScoreSerializer 类声明了一个 Meta 内部类,该类声明了两个属性:model 和 fields。model 属性指定了与序列器相关的模型,即 PlayerScore 类。正如之前解释的那样,我们不在 fields 字符串元组中包含 'player' 字段名称,以避免再次序列化玩家。我们将使用 PlayerSerializer 作为主序列器,而将 ScoreSerializer 作为详细序列器。
现在,将以下代码添加到serializers.py文件中,以声明PlayerSerializer类。示例代码文件包含在restful_python_chapter_02_03文件夹中:
class PlayerSerializer(serializers.HyperlinkedModelSerializer):
scores = ScoreSerializer(many=True, read_only=True)
gender = serializers.ChoiceField(
choices=Player.GENDER_CHOICES)
gender_description = serializers.CharField(
source='get_gender_display',
read_only=True)
class Meta:
model = Player
fields = (
'url',
'name',
'gender',
'gender_description',
'scores',
)
PlayerSerializer类是HyperlinkedModelSerializer类的子类。我们将使用PlayerSerializer类来序列化Player实例,并使用之前声明的ScoreSerializer类来序列化与Player相关的所有PlayerScore实例。
PlayerSerializer类声明了一个scores属性,作为之前编写的ScoreSerializer类的实例。many参数设置为True,因为它是一对多关系。我们使用在创建PlayerScore模型中的player字段时指定的scores名称作为related_name字符串值。这样,scores字段将使用之前声明的ScoreSerializer渲染属于Player的每个PlayerScore。
Player模型将gender声明为models.CharField的实例,其choices属性设置为Player.GENDER_CHOICES字符串元组。ScoreSerializer类声明一个gender属性,作为serializers.ChoiceField的实例,其choices参数设置为Player.GENDER_CHOICES字符串元组。此外,该类还声明了一个gender_description属性,将read_only设置为True,并将source参数设置为'get_gender_display'。source字符串是通过get_后跟字段名gender和_display构建的。这样,只读的gender_description属性将渲染性别选择的描述,而不是存储的单个字符值。
ScoreSerializer类声明了一个Meta内部类,该类声明了两个属性:model和fields。model属性指定与序列化器相关的模型,即PlayerScore类。如前所述,我们不在fields字符串元组中包含'player'字段名,以避免再次序列化玩家。我们将使用PlayerSerializer作为主序列化器,将ScoreSerializer作为详细信息序列化器。
最后,将以下代码添加到serializers.py文件中,以声明PlayerScoreSerializer类。示例代码文件包含在restful_python_chapter_02_03文件夹中:
class PlayerScoreSerializer(serializers.ModelSerializer):
player = serializers.SlugRelatedField(queryset=Player.objects.all(), slug_field='name')
# We want to display the game's name instead of the id
game = serializers.SlugRelatedField(queryset=Game.objects.all(), slug_field='name')
class Meta:
model = PlayerScore
fields = (
'url',
'pk',
'score',
'score_date',
'player',
'game',
)
PlayerScoreSerializer类是HyperlinkedModelSerializer类的子类。我们将使用PlayerScoreSerializer类来序列化PlayerScore实例。之前,我们创建了ScoreSerializer类来序列化PlayerScore实例作为玩家的详细信息。当我们想要显示相关玩家的姓名和相关游戏的名称时,我们将使用新的PlayerScoreSerializer类。在其他serializer类中,我们没有包含任何与玩家相关的信息,并包含了游戏的全部详细信息。
PlayerScoreSerializer 类声明了一个 player 属性,它是一个 serializers.SlugRelatedField 的实例,其 queryset 参数设置为 Player.objects.all(),其 slug_field 参数设置为 'name'。我们在 PlayerScore 模型中创建了一个 player 字段作为 models.ForeignKey 实例,并希望将玩家名称(slug 字段)作为相关 Player 的描述(slug 字段)显示。因此,我们指定了 'name' 作为 slug_field。如果需要在可浏览的 API 中的表单中显示相关游戏类别的可能选项,Django 将使用在 queryset 参数中指定的表达式检索所有可能的玩家并显示他们指定的 slug 字段。
PlayerScoreSerializer 类声明了一个 game 属性,它是一个 serializers.SlugRelatedField 的实例,其 queryset 参数设置为 Game.objects.all(),其 slug_field 参数设置为 'name'。我们在 PlayerScore 模型中创建了一个 game 字段作为 models.ForeignKey 实例,并希望将游戏名称(slug 字段)作为相关 Game 的描述(slug 字段)显示。
创建基于类的视图和使用通用类
这次,我们将通过声明基于类的视图来编写我们的 API 视图,而不是基于函数的视图。我们可能会编写继承自 rest_framework.views.APIView 类的类,并声明与我们要处理的 HTTP 动词(get、post、put、patch、delete 等)具有相同名称的方法。这些方法接收一个 request 参数,就像我们为视图创建的函数一样。然而,这种方法将需要我们编写大量代码。相反,我们可以利用一组通用视图,我们可以将它们用作我们基于类的视图的基础类,以将所需的代码量减少到最小,并利用 Django REST Framework 中已泛化的行为。
我们将创建 rest_framework.generics 中声明的两个通用类视图的子类:
-
ListCreateAPIView:实现了get方法,用于检索查询集的列表,以及post方法,用于创建模型实例。 -
RetrieveUpdateDestroyAPIView:实现了get、put、patch和delete方法,用于检索、完全更新、部分更新或删除模型实例。
这两个通用视图是通过组合 Django REST Framework 中实现的 mixin 类的可重用行为来构成的,这些 mixin 类在 rest_framework.mixins 中声明。我们可以创建一个使用多继承的类,结合许多这些 mixin 类提供的功能。以下行显示了 ListCreateAPIView 类的声明,作为 ListModelMixin、CreateModelMixin 和 rest_framework.generics.GenericAPIView 的组合:
class ListCreateAPIView(mixins.ListModelMixin,
mixins.CreateModelMixin,
GenericAPIView):
以下行显示了 RetrieveUpdateDestroyAPIView 类的声明,它是 RetrieveModelMixin、UpdateModelMixin、DestroyModelMixin 和 rest_framework.generics.GenericAPIView 的组合:
class RetrieveUpdateDestroyAPIView(mixins.RetrieveModelMixin,
mixins.UpdateModelMixin,
mixins.DestroyModelMixin,
GenericAPIView):
现在,我们将创建一个 Django 类视图,它将使用之前解释过的通用类和序列化器类来为我们的 API 将要处理的每个 HTTP 请求返回 JSON 表示。我们只需指定一个 queryset,它检索 queryset 属性中的所有对象,并在每个声明的子类中指定 serializer_class 属性。通用类将为我们完成剩余的工作。此外,我们还将声明一个 name 属性,使用该字符串名称来识别视图。
利用基于通用类的视图
前往 gamesapi/games 文件夹并打开 views.py 文件。用以下代码替换此文件中的代码,该代码声明所需的导入和类视图。我们稍后会向此文件添加更多类。示例的代码文件包含在 restful_python_chapter_02_03 文件夹中:
from games.models import GameCategory
from games.models import Game
from games.models import Player
from games.models import PlayerScore
from games.serializers import GameCategorySerializer
from games.serializers import GameSerializer
from games.serializers import PlayerSerializer
from games.serializers import PlayerScoreSerializer
from rest_framework import generics
from rest_framework.response import Response
from rest_framework.reverse import reverse
class GameCategoryList(generics.ListCreateAPIView):
queryset = GameCategory.objects.all()
serializer_class = GameCategorySerializer
name = 'gamecategory-list'
class GameCategoryDetail(generics.RetrieveUpdateDestroyAPIView):
queryset = GameCategory.objects.all()
serializer_class = GameCategorySerializer
name = 'gamecategory-detail'
class GameList(generics.ListCreateAPIView):
queryset = Game.objects.all()
serializer_class = GameSerializer
name = 'game-list'
class GameDetail(generics.RetrieveUpdateDestroyAPIView):
queryset = Game.objects.all()
serializer_class = GameSerializer
name = 'game-detail'
class PlayerList(generics.ListCreateAPIView):
queryset = Player.objects.all()
serializer_class = PlayerSerializer
name = 'player-list'
class PlayerDetail(generics.RetrieveUpdateDestroyAPIView):
queryset = Player.objects.all()
serializer_class = PlayerSerializer
name = 'player-detail'
class PlayerScoreList(generics.ListCreateAPIView):
queryset = PlayerScore.objects.all()
serializer_class = PlayerScoreSerializer
name = 'playerscore-list'
class PlayerScoreDetail(generics.RetrieveUpdateDestroyAPIView):
queryset = PlayerScore.objects.all()
serializer_class = PlayerScoreSerializer
name = 'playerscore-detail'
以下表格总结了每个基于类的视图将要处理的方法:
| 范围 | 类视图名称 | 它将处理的 HTTP 动词 |
|---|---|---|
游戏类别集合-/game-categories/ |
GameCategoryList |
GET 和 POST |
游戏类别-/game-category/{id}/ |
GameCategoryDetail |
GET, PUT, PATCH 和 DELETE |
游戏集合-/games/ |
GameList |
GET 和 POST |
游戏-/game/{id}/ |
GameDetail |
GET, PUT, PATCH 和 DELETE |
玩家集合-/players/ |
PlayerList |
GET 和 POST |
玩家-/player/{id}/ |
PlayerDetail |
GET, PUT, PATCH 和 DELETE |
分数集合-/player-scores/ |
PlayerScoreList | GET 和 POST |
分数-/player-score/{id}/ |
PlayerScoreDetail | GET, PUT, PATCH 和 DELETE |
此外,我们还将能够在任何范围内执行 OPTIONS HTTP 动词。
与 API 的端点一起工作
我们希望为 API 的根创建一个端点,以便更容易地使用可浏览的 API 功能浏览 API 并了解其工作原理。将以下代码添加到 views.py 文件中,以声明 ApiRoot 类。示例的代码文件包含在 restful_python_chapter_02_03 文件夹中。
class ApiRoot(generics.GenericAPIView):
name = 'api-root'
def get(self, request, *args, **kwargs):
return Response({
'players': reverse(PlayerList.name, request=request),
'game-categories': reverse(GameCategoryList.name, request=request),
'games': reverse(GameList.name, request=request),
'scores': reverse(PlayerScoreList.name, request=request)
})
ApiRoot 类是 rest_framework.generics.GenericAPIView 类的子类,并声明了 get 方法。GenericAPIView 类是所有其他通用视图的基类。ApiRoot 类定义了返回 Response 对象的 get 方法,该对象包含字符串键值对,为视图及其 URL 提供描述性名称,该 URL 由 rest_framework.reverse.reverse 函数生成。此 URL 解析函数返回视图的完全限定 URL。
前往gamesapi/games文件夹并打开urls.py文件。用以下代码替换此文件中的代码。以下行显示了此文件的代码,该代码定义了 URL 模式,该模式指定了请求中必须匹配的正则表达式,以便运行在views.py文件中定义的基于类的视图的特定方法。我们不是指定表示视图的函数,而是调用基于类的视图的as_view方法。我们使用as_view方法。示例代码文件包含在restful_python_chapter_02_03文件夹中:
from django.conf.urls import url
from games import views
urlpatterns = [
url(r'^game-categories/$',
views.GameCategoryList.as_view(),
name=views.GameCategoryList.name),
url(r'^game-categories/(?P<pk>[0-9]+)/$',
views.GameCategoryDetail.as_view(),
name=views.GameCategoryDetail.name),
url(r'^games/$',
views.GameList.as_view(),
name=views.GameList.name),
url(r'^games/(?P<pk>[0-9]+)/$',
views.GameDetail.as_view(),
name=views.GameDetail.name),
url(r'^players/$',
views.PlayerList.as_view(),
name=views.PlayerList.name),
url(r'^players/(?P<pk>[0-9]+)/$',
views.PlayerDetail.as_view(),
name=views.PlayerDetail.name),
url(r'^player-scores/$',
views.PlayerScoreList.as_view(),
name=views.PlayerScoreList.name),
url(r'^player-scores/(?P<pk>[0-9]+)/$',
views.PlayerScoreDetail.as_view(),
name=views.PlayerScoreDetail.name),
url(r'^$',
views.ApiRoot.as_view(),
name=views.ApiRoot.name),
]
当我们编写我们之前的 API 版本时,我们在gamesapi文件夹中的urls.py文件中替换了代码,具体来说,是gamesapi/urls.py文件。我们对根 URL 配置进行了必要的修改,并包含了之前编写的games/urls.py文件中声明的 URL 模式。
现在,我们可以启动 Django 的开发服务器,以编写和发送 HTTP 请求到我们仍然不安全的,但更加复杂的 Web API(我们肯定会稍后添加安全性)。根据您的需求执行以下两个命令之一,以在其他连接到您的局域网的设备或计算机上访问 API。请记住,我们在第一章中分析了它们之间的差异,使用 Django 开发 RESTful API:
python manage.py runserver
python manage.py runserver 0.0.0.0:8000
在我们运行之前的任何命令后,开发服务器将开始监听端口8000。
打开一个网页浏览器并输入http://localhost:8000/或您使用另一台计算机或设备访问可浏览 API 的适当 URL。可浏览的 API 将编写并发送一个GET请求到/,并将显示其执行的输出结果,即从views.py文件中定义的ApiRoot类执行的get方法的头部和 JSON 响应。以下截图显示了在网页浏览器中输入 URL 后的渲染网页,资源描述为:API 根。
API 根提供了查看游戏类别、游戏、玩家和得分的超链接。这样,通过可浏览的 API 访问列表并执行不同资源上的操作变得极其容易。此外,当我们访问其他 URL 时,面包屑导航将允许我们返回到API 根。
在这个 API 的新版本中,我们使用了提供许多功能的通用视图,因此,可浏览的 API 将比之前的版本提供更多功能。点击或轻触游戏类别右侧的 URL。如果你在本地主机上浏览,URL 将是http://localhost:8000/game-categories/。可浏览的 API 将渲染游戏类别列表的网页。
在渲染的网页底部,可浏览的 API 为我们提供了一些控件来生成一个POST请求到/game-categories/。在这种情况下,默认情况下,可浏览的 API 显示 HTML 表单标签,其中包含一个自动生成的表单,我们可以使用它来生成 POST 请求,而无需像我们之前版本那样处理原始数据。HTML 表单使得生成测试 API 的请求变得容易。以下截图显示了创建新游戏类别的 HTML 表单:

我们只需在名称文本框中输入所需的名称,3D RPG,然后点击或轻触POST按钮来创建一个新的游戏类别。可浏览的 API 将组合并发送一个POST请求到/game-categories/,并在网页浏览器中显示调用结果。以下截图显示了网页浏览器显示的 HTTP 状态码201 Created以及之前解释的带有POST按钮的 HTML 表单,允许我们继续在/game-categories/上组合和发送POST请求:

现在,点击显示在 JSON 数据中 url 键值的 URL,例如http://localhost:8000/game-categories/3/。确保将 2 替换为之前渲染的游戏列表中现有游戏类别的 id 或主键。可浏览的 API 将组合并发送一个GET请求到/game-categories/3/,并将显示其执行结果,即游戏类别的头部和 JSON 数据。网页将显示一个删除按钮,因为我们正在处理游戏类别详情视图。
小贴士
我们可以使用面包屑导航回到 API 根目录,并开始创建与游戏类别、玩家和最终与游戏及玩家相关的分数相关的游戏。我们可以通过易于使用的 HTML 表单和可浏览的 API 功能完成所有这些操作。
创建和检索相关资源
现在,我们将使用 HTTPie 命令或其 curl 等价物来组合并发送 HTTP 请求到 API。我们将使用 JSON 进行需要额外数据的请求。记住,你可以使用你喜欢的基于 GUI 的工具或使用可浏览的 API 执行相同的任务。
首先,我们将组合并发送一个 HTTP 请求来创建一个新的游戏类别。记住,我们使用可浏览的 API 创建了一个名为'3D RPG'的游戏类别。
http POST :8000/game-categories/ name='2D mobile arcade'
以下是对应的curl命令:
curl -iX POST -H "Content-Type: application/json" -d '{"name":"2D mobile arcade"}' :8000/game-categories/
前面的命令将组合并发送一个带有指定 JSON 键值对的 POST HTTP 请求。请求指定 /game-categories/,因此它将匹配 '^game-categories/$' 并运行 views.GameCategoryList 类视图的 post 方法。请记住,该方法是在 ListCreateAPIView 超类中定义的,并最终调用在 mixins.CreateModelMixin 中定义的创建方法。如果新的 GameCategory 实例成功持久化到数据库中,对该方法的调用将返回 HTTP 201 Created 状态码,并将最近持久化的 GameCategory 序列化为 JSON 的响应体。以下一行显示了带有新 GameCategory 对象的 HTTP 请求的样本响应。响应不包括头部信息。请注意,响应包括创建的类别的 pk 和 url。games 数组为空,因为没有与新类别相关的游戏:
{
"games": [],
"name": "2D mobile arcade",
"pk": 4,
"url": "http://localhost:8000/game-categories/4/"
}
现在,我们将组合并发送 HTTP 请求来创建属于我们最近创建的第一个类别 3D RPG 的两个游戏。我们将指定 game_category 的值为所需的 game category 名称。然而,持久化 Game 模型的数据库表将保存与提供的名称值匹配的相关 GameCategory 的主键值:
http POST :8000/games/ name='PvZ Garden Warfare 4' game_category='3D RPG' played=false release_date='2016-06-21T03:02:00.776594Z'
http POST :8000/games/ name='Superman vs Aquaman' game_category='3D RPG' played=false release_date='2016-06-21T03:02:00.776594Z'
以下是对应的 curl 命令:
curl -iX POST -H "Content-Type: application/json" -d '{"name":"PvZ Garden Warfare 4", "game_category":"3D RPG", "played": "false", "release_date": "2016-06-21T03:02:00.776594Z"}' :8000/games/
curl -iX POST -H "Content-Type: application/json" -d '{"name":" Superman vs Aquaman", "game_category":"3D RPG", "played": "false", "release_date": "2016-06-21T03:02:00.776594Z"}' :8000/games/
之前的命令将组合并发送两个带有指定 JSON 键值对的 POST HTTP 请求。请求指定 /games/,因此它将匹配 '^games/$' 并运行 views.GameList 类视图的 post 方法。以下几行显示了两个 HTTP 请求的样本响应,其中包含 JSON 响应中的新 Game 对象。响应不包括头部信息。请注意,响应只包括创建的游戏的 url,不包括主键。game_category 的值是相关 GameCategory 的 name:
{
"game_category": "3D RPG",
"name": "PvZ Garden Warfare 4",
"played": false,
"release_date": "2016-06-21T03:02:00.776594Z",
"url": "http://localhost:8000/games/2/"
}
{
"game_category": "3D RPG",
"name": "Superman vs Aquaman",
"played": false,
"release_date": "2016-06-21T03:02:00.776594Z",
"url": "http://localhost:8000/games/3/"
}
我们可以运行之前解释过的命令来检查 Django 在 PostgreSQL 数据库中创建的表的内容。我们会注意到,games_game 表的 game_category_id 列保存了 games_game_category 表中相关行的主键值。GameSerializer 类使用 SlugRelatedField 来显示相关 GameCategory 的名称值。以下截图显示了在运行 HTTP 请求后 PostgreSQL 数据库中 games_game_category 和 games_game 表的内容:

现在,我们将组合并发送一个 HTTP 请求来检索包含两个游戏的游戏类别,即 ID 或主键等于 3 的游戏类别资源。不要忘记将 3 替换为您配置中名称等于 '3D RPG' 的游戏的主键值:
http :8000/game-categories/3/
以下是对应的 curl 命令:
curl -iX GET :8000/game-categories/3/
之前的命令将编写并发送以下 HTTP 请求:GET http://localhost:8000/game-categories/3/。请求在 /game-categories/ 后面有一个数字,因此它将匹配 '^game-categories/(?P<pk>[0-9]+)/$' 并运行基于 views.GameCategoryDetail 类视图的 get 方法。请记住,该方法是在 RetrieveUpdateDestroyAPIView 超类中定义的,并最终调用在 mixins.RetrieveModelMixin 中定义的 retrieve 方法。以下几行显示了 HTTP 请求的样本响应,其中包含 JSON 响应中的 GameCategory 对象和相关游戏的超链接:
HTTP/1.0 200 OK
Allow: GET, PUT, PATCH, DELETE, HEAD, OPTIONS
Content-Type: application/json
Date: Tue, 21 Jun 2016 23:32:04 GMT
Server: WSGIServer/0.2 CPython/3.5.1
Vary: Accept, Cookie
X-Frame-Options: SAMEORIGIN
{
"games": [
"http://localhost:8000/games/2/",
"http://localhost:8000/games/3/"
],
"name": "3D RPG",
"pk": 3,
"url": "http://localhost:8000/game-categories/3/"
}
GameCategorySerializer 类将 games 属性定义为 HyperlinkedRelatedField,因此序列化器会在 games 数组的值中渲染每个相关 Game 实例的 URL。如果我们通过可浏览的 API 在网页浏览器中查看结果,我们将能够点击或轻触超链接以查看每个游戏的详细信息。
现在,我们将编写并发送一个创建与不存在游戏类别名称相关的游戏的 POST HTTP 请求:'Virtual reality':
http POST :8000/games/ name='Captain America vs Thor' game_category='Virtual reality' played=false release_date='2016-06-21T03:02:00.776594Z'
以下是对应的 curl 命令:
curl -iX POST -H "Content-Type: application/json" -d '{"name":"'Captain America vs Thor", "game_category":"Virtual reality", "played": "false", "release_date": "2016-06-21T03:02:00.776594Z"}' :8000/games/
Django 无法检索一个 name 等于指定值的 GameCategory 实例,因此我们将在响应头部收到 400 Bad Request 状态码,并在 JSON 体的 game_category 中指定相关消息。以下几行显示了样本响应:
HTTP/1.0 400 Bad Request
Allow: GET, POST, HEAD, OPTIONS
Content-Type: application/json
Date: Tue, 21 Jun 2016 23:51:19 GMT
Server: WSGIServer/0.2 CPython/3.5.1
Vary: Accept, Cookie
X-Frame-Options: SAMEORIGIN
{
"game_category": [
"Object with name=Virtual reality does not exist."
]
}
现在,我们将编写并发送 HTTP 请求来创建两个玩家:
http POST :8000/players/ name='Brandon' gender='M'
http POST :8000/players/ name='Kevin' gender='M'
以下是对应的 curl 命令:
curl -iX POST -H "Content-Type: application/json" -d '{"name":"Brandon", "gender":"M"}' :8000/players/
curl -iX POST -H "Content-Type: application/json" -d '{"name":" Kevin", "gender":"M"}' :8000/players/
之前的命令将编写并发送两个带有指定 JSON 键值对的 POST HTTP 请求。请求指定 /players/,因此它将匹配 '^players/$' 并运行 views.PlayerList 类视图的 post 方法。以下几行显示了两个 HTTP 请求的样本响应,其中包含 JSON 响应中的新 Player 对象。响应不包括头部信息。注意,响应仅包括创建的玩家的 url,而不包括主键。gender_description 的值是 gender 字段的选项描述。scores 数组为空,因为没有与每个新玩家相关的分数:
{
"gender": "M",
"name": "Brandon",
"scores": [],
"url": "http://localhost:8000/players/2/"
}
{
"gender": "M",
"name": "Kevin",
"scores": [],
"url": "http://localhost:8000/players/3/"
}
现在,我们将编写并发送 HTTP 请求来创建四个分数:
http POST :8000/player-scores/ score=35000 score_date='2016-06-21T03:02:00.776594Z' player='Brandon' game='PvZ Garden Warfare 4'
http POST :8000/player-scores/ score=85125 score_date='2016-06-22T01:02:00.776594Z' player='Brandon' game='PvZ Garden Warfare 4'
http POST :8000/player-scores/ score=123200 score_date='2016-06-22T03:02:00.776594Z' player='Kevin' game='Superman vs Aquaman'
http POST :8000/player-scores/ score=11200 score_date='2016-06-22T05:02:00.776594Z' player='Kevin' game='PvZ Garden Warfare 4'
以下是对应的 curl 命令:
curl -iX POST -H "Content-Type: application/json" -d '{"score":"35000", "score_date":"2016-06-21T03:02:00.776594Z", "player":"Brandon", "game":"PvZ Garden Warfare 4"}' :8000/player-scores/
curl -iX POST -H "Content-Type: application/json" -d '{"score":"85125", "score_date":"2016-06-22T01:02:00.776594Z", "player":"Brandon", "game":"PvZ Garden Warfare 4"}' :8000/player-scores/
curl -iX POST -H "Content-Type: application/json" -d '{"score":"123200", "score_date":"2016-06-22T03:02:00.776594Z", "player":"Kevin", "game":"'Superman vs Aquaman"}' :8000/player-scores/
curl -iX POST -H "Content-Type: application/json" -d '{"score":"11200", "score_date":"2016-06-22T05:02:00.776594Z", "player":"Kevin", "game":"PvZ Garden Warfare 4"}' :8000/player-scores/
之前的命令将编写并发送四个带有指定 JSON 键值对的 POST HTTP 请求。请求指定 /player-scores/,因此它将匹配 '^player-scores/$' 并运行 views.PlayerScoreList 类视图的 post 方法。以下几行显示了四个 HTTP 请求的样本响应,其中包含 JSON 响应中的新 Player 对象。响应不包括头部信息。
Django REST Framework 使用PlayerScoreSerializer类来生成 JSON 响应。因此,game的值是相关Game实例的名称,而player的值是相关Player实例的名称。PlayerScoreSerializer类为这两个字段都使用了SlugRelatedField:
{
"game": "PvZ Garden Warfare 4",
"pk": 3,
"player": "Brandon",
"score": 35000,
"score_date": "2016-06-21T03:02:00.776594Z",
"url": "http://localhost:8000/player-scores/3/"
}
{
"game": "PvZ Garden Warfare 4",
"pk": 4,
"player": "Brandon",
"score": 85125,
"score_date": "2016-06-22T01:02:00.776594Z",
"url": "http://localhost:8000/player-scores/4/"
}
{
"game": "Superman vs Aquaman",
"pk": 5,
"player": "Kevin",
"score": 123200,
"score_date": "2016-06-22T03:02:00.776594Z",
"url": "http://localhost:8000/player-scores/5/"
}
{
"game": "PvZ Garden Warfare 4",
"pk": 6,
"player": "Kevin",
"score": 11200,
"score_date": "2016-06-22T05:02:00.776594Z",
"url": "http://localhost:8000/player-scores/6/"
}
我们可以运行之前解释的命令来检查 Django 在 PostgreSQL 数据库中创建的表的内容。我们会注意到games_playerscore表的game_id列保存了games_game表中相关行的主键值。此外,games_playerscore表的player_id列保存了games_player表中相关行的主键值。以下截图显示了在运行 HTTP 请求后,PostgreSQL 数据库中games_game_category、games_game、games_player和games_playerscore表的内容:

现在,我们将组合并发送一个 HTTP 请求来检索包含两个分数的特定玩家,该玩家的 id 或主键等于3。不要忘记将3替换为配置中名称等于'Kevin'的玩家的主键值:
http :8000/players/3/
以下是对应的 curl 命令:
curl -iX GET :8000/players/3/
之前的命令将组合并发送以下 HTTP 请求:GET http://localhost:8000/players/3/。请求在/players/之后有一个数字,因此,它将匹配'^players/(?P<pk>[0-9]+)/$'并运行views.PlayerDetail类的视图方法。记住,该方法是在RetrieveUpdateDestroyAPIView超类中定义的,并且最终调用在mixins.RetrieveModelMixin中定义的retrieve方法。以下行显示了 HTTP 请求的示例响应,其中包含Player对象、相关的PlayerScore对象以及与每个PlayerScore对象相关的Game对象,在 JSON 响应中:
HTTP 200 OK
Allow: GET, PUT, PATCH, DELETE, HEAD, OPTIONS
Content-Type: application/json
Vary: Accept
{
"url": "http://localhost:8000/players/3/",
"name": "Kevin",
"gender": "M",
"gender_description": "Male",
"scores": [
{
"url": "http://localhost:8000/player-scores/5/",
"pk": 5,
"score": 123200,
"score_date": "2016-06-22T03:02:00.776594Z",
"game": {
"url": "http://localhost:8000/games/3/",
"game_category": "3D RPG",
"name": "Superman vs Aquaman",
"release_date": "2016-06-21T03:02:00.776594Z",
"played": false
}
},
{
"url": "http://localhost:8000/player-scores/6/",
"pk": 6,
"score": 11200,
"score_date": "2016-06-22T05:02:00.776594Z",
"game": {
"url": "http://localhost:8000/games/2/",
"game_category": "3D RPG",
"name": "PvZ Garden Warfare 4",
"release_date": "2016-06-21T03:02:00.776594Z",
"played": false
}
}
]
}
PlayerSerializer类将scores属性定义为具有many=True的ScoreSerializer,因此,这个序列化器渲染与玩家相关的每个分数。ScoreSerializer类将game属性定义为GameSerializer,因此,这个序列化器渲染与分数相关的每个游戏。如果我们通过可浏览的 API 在网页浏览器中查看结果,我们将能够点击或轻触每个相关资源的超链接。然而,在这种情况下,我们还可以看到所有它们的详细信息,而无需跟随超链接。
测试你的知识
-
在内部,
@api_view装饰器是:-
一个将基于函数的视图转换为
rest_framework.views.APIView类子类的包装器。 -
一个将基于函数的视图转换为序列化器的包装器。
-
一个将基于函数的视图转换为
rest_framework.views.api_view类子类的包装器。
-
-
可浏览的 API,这是 Django REST Framework 中包含的一个特性:
-
当请求指定请求头中的
Content-type键值为application/json时,为每个资源生成人类友好的 JSON 输出。 -
当请求指定请求头中的
Content-type键值为text/html时,为每个资源生成人类友好的 HTML 输出。 -
当请求指定请求头中的
Content-type键值为application/json时,为每个资源生成人类友好的 JSON 输出。
-
-
rest_framework.serializers.ModelSerializer类:-
自动填充一组默认约束和一组默认解析器。
-
自动填充一组默认字段,但不会自动填充一组默认验证器。
自动填充一组默认字段,但不会自动填充一组默认验证器。自动填充一组默认字段和一组默认验证器。
-
-
rest_framework.serializers.ModelSerializer类:-
为
get和patch方法提供默认实现。 -
为
get和put方法提供默认实现。 -
为
create和update方法提供默认实现。
-
-
Django REST Framework 中的
Serializer和ModelSerializer类类似于 Django Web Framework 中的以下两个类:-
Form和ModelForm类。 -
View和ModelView类。 -
Controller和ModelController类。
-
摘要
在本章中,我们利用了 Django REST Framework 中包含的各种特性,这些特性使我们能够消除重复代码并重用通用行为来构建我们的 API。我们使用了模型序列化器、包装器、默认解析和渲染选项、基于类的视图和通用类。
我们使用了可浏览的 API 特性,并设计了一个与复杂的 PostgreSQL 数据库交互的 RESTful API。我们声明了与模型的关联,使用关系管理序列化和反序列化,以及超链接。最后,我们创建了相关资源并检索了它们,我们理解了内部的工作原理。
现在我们已经使用 Django REST Framework 构建了一个复杂的 API,我们将使用框架中包含的额外抽象来改进我们的 API,我们将添加安全和认证,这是我们将在下一章讨论的内容。
第三章. 使用 Django 改进和添加身份验证到 API
在本章中,我们将改进上一章开始构建的 RESTful API,并为其添加与身份验证相关的安全功能。我们将:
-
为模型添加唯一约束
-
使用
PATCH方法更新资源的单个字段 -
利用分页功能
-
自定义分页类
-
理解身份验证、权限和节流
-
将安全相关数据添加到模型中
-
为对象级权限创建自定义权限类
-
持久化发起请求的用户
-
配置权限策略
-
在迁移中为新的必填字段设置默认值
-
使用必要的身份验证来组合请求
-
使用身份验证凭据浏览 API
为模型添加唯一约束
我们的 API 存在一些需要解决的问题。目前,可以创建许多具有相同名称的游戏类别。我们不应该能够这样做,因此,我们将对 GameCategory 模型进行必要的更改,以在 name 字段上添加唯一约束。我们还将为 Game 和 Player 模型的 name 字段添加唯一约束。这样,我们将学习必要的步骤来更改多个模型的约束,并通过迁移在底层数据库中反映这些更改。
确保您已退出 Django 的开发服务器。请记住,您只需在运行它的终端或命令提示符窗口中按 Ctrl + C 即可。现在,我们将进行更改,为用于表示和持久化游戏类别、游戏和玩家的模型引入对名称字段的唯一约束。打开 games/models.py 文件,将声明 GameCategory、Game 和 Player 类的代码替换为以下代码。代码列表中突出显示的三个行发生了变化。PlayerScore 类的代码保持不变。示例代码文件包含在 restful_python_chapter_03_01 文件夹中,如下所示:
class GameCategory(models.Model):
name = models.CharField(max_length=200, unique=True)
class Meta:
ordering = ('name',)
def __str__(self):
return self.name
class Game(models.Model):
created = models.DateTimeField(auto_now_add=True)
name = models.CharField(max_length=200, unique=True)
game_category = models.ForeignKey(
GameCategory,
related_name='games',
on_delete=models.CASCADE)
release_date = models.DateTimeField()
played = models.BooleanField(default=False)
class Meta:
ordering = ('name',)
def __str__(self):
return self.name
class Player(models.Model):
MALE = 'M'
FEMALE = 'F'
GENDER_CHOICES = (
(MALE, 'Male'),
(FEMALE, 'Female'),
)
created = models.DateTimeField(auto_now_add=True)
name = models.CharField(max_length=50, blank=False, default='', unique=True)
gender = models.CharField(
max_length=2,
choices=GENDER_CHOICES,
default=MALE,
)
class Meta:
ordering = ('name',)
def __str__(self):
return self.name
我们只需要将 unique=True 作为 models.CharField 的命名参数之一添加。这样,我们表明该字段必须是唯一的,Django 将为底层数据库表中的字段创建必要的唯一约束。
现在,运行以下 Python 脚本来生成迁移,这将允许我们同步数据库,以与我们在模型字段中添加的唯一约束保持一致:
python manage.py makemigrations games
以下行显示了运行上一条命令后生成的输出:
Migrations for 'games':
0002_auto_20160623_2131.py:
- Alter field name on game
- Alter field name on gamecategory
- Alter field name on player
输出表明gamesapi/games/migrations/0002_auto_20160623_2131.py文件包含了更改game、gamecategory和player上名为name的字段的代码。请注意,由于它包含编码的日期和时间,生成的文件名在您的配置中可能会有所不同。以下行显示了此文件的代码,该代码由 Django 自动生成。示例的代码文件包含在restful_python_chapter_03_01文件夹中:
# -*- coding: utf-8 -*-
# Generated by Django 1.9.7 on 2016-06-23 21:31
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('games', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='game',
name='name',
field=models.CharField(max_length=200, unique=True),
),
migrations.AlterField(
model_name='gamecategory',
name='name',
field=models.CharField(max_length=200, unique=True),
),
migrations.AlterField(
model_name='player',
name='name',
field=models.CharField(default='', max_length=50, unique=True),
),
]
代码定义了一个名为Migration的django.db.migrations.Migration类的子类,该类定义了一个包含许多migrations.AlterField的operations列表。每个migrations.AlterField将更改相关模型中每个相关表的字段。
现在,运行以下 Python 脚本来应用所有生成的迁移并在数据库表中执行更改:
python manage.py migrate
以下行显示了运行上述命令后生成的输出。请注意,迁移的顺序在您的配置中可能会有所不同。
Operations to perform:
Operations to perform:
Apply all migrations: admin, auth, contenttypes, games, sessions
Running migrations:
Rendering model states... DONE
Applying games.0002_auto_20160623_2131... OK
在我们运行前面的命令之后,我们将在 PostgreSQL 数据库的games_game、games_gamecategory和games_player表上的name字段上拥有唯一的索引。我们可以使用 PostgreSQL 命令行或任何允许我们轻松检查 PostreSQL 数据库内容的其他应用程序来检查 Django 更新的表。如果您决定继续使用 SQLite,请使用与此数据库相关的命令或工具。
现在,我们可以启动 Django 的开发服务器来编写和发送 HTTP 请求。根据您的需求执行以下两个命令之一,以在其他连接到您的局域网的其他设备或计算机上访问 API。请记住,我们在第一章中分析了它们之间的区别:使用 Django 开发 RESTful API:
python manage.py runserver
python manage.py runserver 0.0.0.0:8000
在我们运行上述任何命令之后,开发服务器将开始监听端口8000。
现在,我们将编写并发送一个 HTTP 请求来创建一个名为'3D RPG'的游戏类别:
http POST :8000/game-categories/ name='3D RPG'
以下是对应的curl命令:
curl -iX POST -H "Content-Type: application/json" -d '{"name":"3D RPG"}'
:8000/game-categories/
Django 无法持久化GameCategory实例,其实例的name等于指定的值,因为这会违反添加到name字段的唯一约束。因此,我们将在响应头中收到400 Bad Request状态码,并在 JSON 正文中收到与name指定的值相关的消息。以下行显示了示例响应:
HTTP/1.0 400 Bad Request
Allow: GET, POST, HEAD, OPTIONS
Content-Type: application/json
Date: Sun, 26 Jun 2016 03:37:05 GMT
Server: WSGIServer/0.2 CPython/3.5.1
Vary: Accept, Cookie
X-Frame-Options: SAMEORIGIN
{
"name": [
"GameCategory with this name already exists."
]
}
在我们进行了更改之后,我们无法在游戏类别、游戏或玩家中为name字段添加重复的值。这样,我们可以确保每次我们指定这些资源的名称时,我们都会引用相同的唯一资源。
使用 PATCH 方法更新资源的单个字段
正如我们在第二章中解释的那样,在 Django 中使用基于类的视图和超链接 API,我们的 API 可以更新现有资源的单个字段,因此,我们提供了一个PATCH方法的实现。例如,我们可以使用PATCH方法来更新一个现有的游戏,并将它的played字段的值设置为true。我们不希望使用PUT方法,因为这个方法旨在替换整个游戏。PATCH方法旨在将增量应用到现有游戏上,因此,它是仅更改played字段值的适当方法。
现在,我们将组合并发送一个 HTTP 请求来更新一个现有的游戏,具体来说,是更新played字段的值并将其设置为true,因为我们只想更新单个字段,所以我们将使用PATCH方法而不是PUT。确保将2替换为您配置中现有游戏的 id 或主键:
http PATCH :8000/games/2/ played=true
以下是对应的curl命令:
curl -iX PATCH -H "Content-Type: application/json" -d '{"played":"true"}'
:8000/games/2/
前面的命令将组合并发送一个带有指定 JSON 键值对的PATCH HTTP 请求。请求在/games/之后有一个数字,因此,它将匹配'^games/(?P<pk>[0-9]+)/$'并运行views.GameDetail基于类的视图的patch方法。请记住,该方法是在RetrieveUpdateDestroyAPIView超类中定义的,并且最终会调用在mixins.UpdateModelMixin中定义的update方法。如果具有更新played字段值的Game实例有效并且已成功持久化到数据库中,对该方法的调用将返回200 OK状态码,并将最近更新的Game序列化为 JSON 格式放在响应体中。以下行显示了示例响应:
HTTP/1.0 200 OK
Allow: GET, PUT, PATCH, DELETE, HEAD, OPTIONS
Content-Type: application/json
Date: Sun, 26 Jun 2016 04:09:22 GMT
Server: WSGIServer/0.2 CPython/3.5.1
Vary: Accept, Cookie
X-Frame-Options: SAMEORIGIN
{
"game_category": "3D RPG",
"name": "PvZ Garden Warfare 4",
"played": true,
"release_date": "2016-06-21T03:02:00.776594Z",
"url": "http://localhost:8000/games/2/"
}
利用分页功能
我们的数据库在每个表中都有几行,用于持久化我们定义的模型。然而,在我们开始在现实生活中的生产环境中使用我们的 API 之后,我们将有数千个玩家得分、玩家、游戏和游戏类别,因此,我们将不得不处理大量结果集。我们可以利用 Django REST Framework 中可用的分页功能,使其容易指定我们希望如何将大量结果集拆分为单个数据页。
首先,我们将组合并发送 HTTP 请求来创建属于我们创建的某个类别(例如2D mobile arcade)的 10 个游戏。这样,我们将有总共 12 个游戏持久保存在数据库中。我们原本有 2 个游戏,并将再添加 10 个:
http POST :8000/games/ name='Tetris Reloaded' game_category='2D mobile arcade' played=false release_date='2016-06-21T03:02:00.776594Z'
http POST :8000/games/ name='Puzzle Craft' game_category='2D mobile arcade' played=false release_date='2016-06-21T03:02:00.776594Z'
http POST :8000/games/ name='Blek' game_category='2D mobile arcade' played=false release_date='2016-06-21T03:02:00.776594Z'
http POST :8000/games/ name='Scribblenauts Unlimited' game_category='2D mobile arcade' played=false release_date='2016-06-21T03:02:00.776594Z'
http POST :8000/games/ name='Cut the Rope: Magic' game_category='2D mobile arcade' played=false release_date='2016-06-21T03:02:00.776594Z'
http POST :8000/games/ name='Tiny Dice Dungeon' game_category='2D mobile arcade' played=false release_date='2016-06-21T03:02:00.776594Z'
http POST :8000/games/ name='A Dark Room' game_category='2D mobile arcade' played=false release_date='2016-06-21T03:02:00.776594Z'
http POST :8000/games/ name='Bastion' game_category='2D mobile arcade' played=false release_date='2016-06-21T03:02:00.776594Z'
http POST :8000/games/ name='Welcome to the Dungeon' game_category='2D mobile arcade' played=false release_date='2016-06-21T03:02:00.776594Z'
http POST :8000/games/ name='Dust: An Elysian Tail' game_category='2D mobile arcade' played=false release_date='2016-06-21T03:02:00.776594Z'
以下是对应的curl命令:
curl -iX POST -H "Content-Type: application/json" -d '{"name":"Tetris Reloaded", "game_category":"2D mobile arcade", "played": "false", "release_date": "2016-06-21T03:02:00.776594Z"}' :8000/games/
curl -iX POST -H "Content-Type: application/json" -d '{"name":"Puzzle Craft", "game_category":"2D mobile arcade", "played": "false", "release_date": "2016-06-21T03:02:00.776594Z"}' :8000/games/
curl -iX POST -H "Content-Type: application/json" -d '{"name":"Blek", "game_category":"2D mobile arcade", "played": "false", "release_date": "2016-06-21T03:02:00.776594Z"}' :8000/games/
curl -iX POST -H "Content-Type: application/json" -d '{"name":"Scribblenauts Unlimited", "game_category":"2D mobile arcade", "played": "false", "release_date": "2016-06-21T03:02:00.776594Z"}' :8000/games/
curl -iX POST -H "Content-Type: application/json" -d '{"name":"Cut the Rope: Magic", "game_category":"2D mobile arcade", "played": "false", "release_date": "2016-06-21T03:02:00.776594Z"}' :8000/games/
curl -iX POST -H "Content-Type: application/json" -d '{"name":"Tiny Dice Dungeon", "game_category":"2D mobile arcade", "played": "false", "release_date": "2016-06-21T03:02:00.776594Z"}' :8000/games/
curl -iX POST -H "Content-Type: application/json" -d '{"name":"A Dark Room", "game_category":"2D mobile arcade", "played": "false", "release_date": "2016-06-21T03:02:00.776594Z"}' :8000/games/
curl -iX POST -H "Content-Type: application/json" -d '{"name":"Bastion", "game_category":"2D mobile arcade", "played": "false", "release_date": "2016-06-21T03:02:00.776594Z"}' :8000/games/
curl -iX POST -H "Content-Type: application/json" -d '{"name":"Welcome to the Dungeon", "game_category":"2D mobile arcade", "played": "false", "release_date": "2016-06-21T03:02:00.776594Z"}' :8000/games/
curl -iX POST -H "Content-Type: application/json" -d '{"name":"Dust: An Elysian Tail", "game_category":"2D mobile arcade", "played": "false", "release_date": "2016-06-21T03:02:00.776594Z"}' :8000/games/
前面的命令将组合并发送十个带有指定 JSON 键值对的POST HTTP 请求。请求指定/games/,因此,它将匹配'^games/$'并运行views.GameList基于类的视图的post方法。
现在,我们的数据库中有 12 场比赛。然而,当我们向/games/发送一个GET HTTP 请求来组合和发送时,我们不想检索这 12 场比赛。我们将配置 Django REST Framework 中包含的可定制的分页样式之一,以在每个数据页面上包含最多五个资源。
提示
我们的 API 使用与可以处理分页响应的混合类一起工作的通用视图,因此,它们将自动考虑我们在 Django REST Framework 中配置的分页设置。
打开gamesapi/settings.py文件,并添加以下行,声明一个名为REST_FRAMEWORK的字典,其中包含配置全局分页设置的键值对。示例代码文件包含在restful_python_chapter_03_02文件夹中:
REST_FRAMEWORK = {
'DEFAULT_PAGINATION_CLASS':
'rest_framework.pagination.LimitOffsetPagination',
'PAGE_SIZE': 5
}
DEFAULT_PAGINATION_CLASS设置键的值指定了一个全局设置,即通用视图将使用的默认分页类,以提供分页响应。在这种情况下,我们将使用rest_framework.pagination.LimitOffsetPagination类,它提供基于限制/偏移的分页样式。这种分页样式与limit一起工作,表示要返回的最大项目数,以及指定查询起始位置的offset。PAGE_SIZE设置键的值指定了一个全局设置,即limit的默认值,也称为页面大小。我们可以在执行 HTTP 请求时指定不同的限制值,通过在limit查询参数中指定所需的值。我们可以配置类以具有最大的limit值,以避免不希望的大结果集。
现在,我们将组合并发送一个 HTTP 请求来检索所有比赛,具体来说,是以下 HTTP GET方法到/games/:
http GET :8000/games/
以下是对应的curl命令:
curl -iX GET :8000/games/
通用视图将使用我们添加的新设置来启用偏移/限制分页,并且结果将为我们提供前 5 个游戏资源(results键),查询的游戏总数(count键),以及指向下一页(next键)和上一页(previous键)的链接。在这种情况下,结果集是第一页,因此,上一页的链接(previous key)是null。我们将在响应头中收到200 OK状态码,以及results数组中的 5 场比赛:
HTTP/1.0 200 OK
Allow: GET, POST, HEAD, OPTIONS
Content-Type: application/json
Date: Fri, 01 Jul 2016 00:57:55 GMT
Server: WSGIServer/0.2 CPython/3.5.1
Vary: Accept, Cookie
X-Frame-Options: SAMEORIGIN
{
"count": 12,
"next": "http://localhost:8000/games/?limit=5&offset=5",
"previous": null,
"results": [
{
"game_category": "2D mobile arcade",
"name": "A Dark Room",
"played": false,
"release_date": "2016-06-21T03:02:00.776594Z",
"url": "http://localhost:8000/games/10/"
},
{
"game_category": "2D mobile arcade",
"name": "Bastion",
"played": false,
"release_date": "2016-06-21T03:02:00.776594Z",
"url": "http://localhost:8000/games/11/"
},
{
"game_category": "2D mobile arcade",
"name": "Blek",
"played": false,
"release_date": "2016-06-21T03:02:00.776594Z",
"url": "http://localhost:8000/games/6/"
},
{
"game_category": "2D mobile arcade",
"name": "Cut the Rope: Magic",
"played": false,
"release_date": "2016-06-21T03:02:00.776594Z",
"url": "http://localhost:8000/games/8/"
},
{
"game_category": "2D mobile arcade",
"name": "Dust: An Elysian Tail",
"played": false,
"release_date": "2016-06-21T03:02:00.776594Z",
"url": "http://localhost:8000/games/13/"
}
]
}
在前面的 HTTP 请求中,我们没有指定限制或偏移参数的任何值。然而,由于我们在全局设置中指定了默认的限制值为 5 项,通用视图使用此配置值并为我们提供第一页。如果我们通过指定偏移值为1来组合并发送以下 HTTP 请求以检索所有游戏的首页,API 将提供之前显示的相同结果:
http GET ':8000/games/?offset=0'
以下是对应的curl命令:
curl -iX GET ':8000/games/?offset=0'
如果我们编写并发送以下 HTTP 请求以通过将偏移量值指定为 0 和限制值指定为 5 来检索所有游戏的首页,API 也将提供与前面所示相同的相同结果:
http GET ':8000/games/?limit=5&offset=0'
以下是对应的 curl 命令:
curl -iX GET ':8000/games/?limit=5&offset=0'
现在,我们将编写并发送一个 HTTP 请求以检索下一页,即游戏的第二页,具体是一个到 /games/ 的 HTTP GET 方法,并将 offset 值设置为 5。请记住,前一个结果 JSON 体的 next 键返回的值为我们提供了下一页的 URL:
http GET ':8000/games/?limit=5&offset=5'
以下是对应的 curl 命令:
curl -iX GET ':8000/games/?limit=5&offset=5'
结果将为我们提供第 5 个游戏资源集(results 键),查询到的游戏总数(count 键),以及指向下一页(next 键)和上一页(previous 键)的链接。在这种情况下,结果集是第二页,因此,指向上一页的链接(previous 键)是 http://localhost:8000/games/?limit=5。我们将在响应头中收到 200 OK 状态码,并在 results 数组中收到 5 个游戏:
HTTP/1.0 200 OK
Allow: GET, POST, HEAD, OPTIONS
Content-Type: application/json
Date: Fri, 01 Jul 2016 01:25:10 GMT
Server: WSGIServer/0.2 CPython/3.5.1
Vary: Accept, Cookie
X-Frame-Options: SAMEORIGIN
{
"count": 12,
"next": "http://localhost:8000/games/?limit=5&offset=10",
"previous": "http://localhost:8000/games/?limit=5",
"results": [
{
"game_category": "2D mobile arcade",
"name": "Puzzle Craft",
"played": false,
"release_date": "2016-06-21T03:02:00.776594Z",
"url": "http://localhost:8000/games/5/"
},
{
"game_category": "3D RPG",
"name": "PvZ Garden Warfare 4",
"played": true,
"release_date": "2016-06-21T03:02:00.776594Z",
"url": "http://localhost:8000/games/2/"
},
{
"game_category": "2D mobile arcade",
"name": "Scribblenauts Unlimited",
"played": false,
"release_date": "2016-06-21T03:02:00.776594Z",
"url": "http://localhost:8000/games/7/"
},
{
"game_category": "3D RPG",
"name": "Superman vs Aquaman",
"played": true,
"release_date": "2016-06-21T03:02:00.776594Z",
"url": "http://localhost:8000/games/3/"
},
{
"game_category": "2D mobile arcade",
"name": "Tetris Reloaded",
"played": false,
"release_date": "2016-06-21T03:02:00.776594Z",
"url": "http://localhost:8000/games/4/"
}
]
}
在先前的 HTTP 请求中,我们为 limit 和 offset 参数指定了值。然而,由于我们在全局设置中将 limit 的默认值指定为 5 项,因此以下请求将产生与先前请求相同的结果:
http GET ':8000/games/?offset=5'
以下是对应的 curl 命令:
curl -iX GET ':8000/games/?offset=5'
最后,我们将编写并发送一个 HTTP 请求以检索最后一页,即游戏的第三页,具体是一个到 /games/ 的 HTTP GET 方法,并将 offset 值设置为 10。请记住,前一个结果 JSON 体的 next 键返回的值为我们提供了下一页的 URL:
http GET ':8000/games/?limit=5&offset=10'
以下是对应的 curl 命令:
curl -iX GET ':8000/games/?limit=5&offset=10'
结果将为我们提供最后一个包含 2 个游戏资源(results 键),查询到的游戏总数(count 键),以及指向下一页(next 键)和上一页(previous 键)的链接。在这种情况下,结果集是最后一页,因此,指向下一页的链接(next 键)是 null。我们将在响应头中收到 200 OK 状态码,并在 results 数组中收到 2 个游戏:
HTTP/1.0 200 OK
Allow: GET, POST, HEAD, OPTIONS
Content-Type: application/json
Date: Fri, 01 Jul 2016 01:28:13 GMT
Server: WSGIServer/0.2 CPython/3.5.1
Vary: Accept, Cookie
X-Frame-Options: SAMEORIGIN
{
"count": 12,
"next": null,
"previous": "http://localhost:8000/games/?limit=5&offset=5",
"results": [
{
"game_category": "2D mobile arcade",
"name": "Tiny Dice Dungeon",
"played": false,
"release_date": "2016-06-21T03:02:00.776594Z",
"url": "http://localhost:8000/games/9/"
},
{
"game_category": "2D mobile arcade",
"name": "Welcome to the Dungeon",
"played": false,
"release_date": "2016-06-21T03:02:00.776594Z",
"url": "http://localhost:8000/games/12/"
}
]
}
自定义分页类
我们使用的rest_framework.pagination.LimitOffsetPagination类,用于提供分页响应,声明了一个max_limit类属性,默认值为None。此属性允许我们指定可以使用limit查询参数指定的最大允许限制。使用默认设置,没有限制,我们将能够处理指定1000000作为限制查询参数值的请求。我们绝对不希望我们的 API 能够通过单个请求生成包含一百万个玩家分数或玩家的响应。不幸的是,没有设置可以更改类分配给max_limit类属性的值。因此,我们将创建 Django REST Framework 提供的限制/偏移分页样式的自定义版本。
在games文件夹中创建一个名为pagination.py的新 Python 文件,并输入以下代码,该代码声明了新的LimitOffsetPaginationWithMaxLimit类。示例代码文件包含在restful_python_chapter_03_03文件夹中:
from rest_framework.pagination import LimitOffsetPagination
class LimitOffsetPaginationWithMaxLimit(LimitOffsetPagination):
max_limit = 10
前面的行声明了LimitOffsetPaginationWithMaxLimit类作为rest_framework.pagination.LimitOffsetPagination类的子类,并覆盖了为max_limit类属性指定的值,将其设置为10。
打开gamesapi/settings.py文件,将指定REST_FRAMEWORK字典中DEFAULT_PAGINATION_CLASS键值的行替换为高亮显示的行。以下行显示了名为REST_FRAMEWORK的新字典声明。示例代码文件包含在restful_python_chapter_03_03文件夹中:
REST_FRAMEWORK = {
'DEFAULT_PAGINATION_CLASS':
'games.pagination.LimitOffsetPaginationWithMaxLimit',
'PAGE_SIZE': 5
}
现在,通用视图将使用最近声明的games.pagination.LimitOffsetPaginationWithMaxLimit类,该类提供了一个最大limit值为10的限制/偏移分页样式。如果请求指定了大于10的限制值,该类将使用最大限制值,即 10,并且我们永远不会在分页响应中返回超过10个条目。
现在,我们将编写并发送一个 HTTP 请求来检索游戏的第一个页面,具体是一个设置limit值为10000的/games/的 HTTP GET方法:
http GET ':8000/games/?limit=10000'
以下是对应的 curl 命令:
curl -iX GET ':8000/games/?limit=10000'
结果将使用10作为限制值,而不是指示的10000,因为我们正在使用我们自定义的分页类。结果将提供包含 10 个游戏资源的第一个集合(results键),查询的游戏总数(count键),以及指向下一页(next键)和上一页(previous键)的链接。在这种情况下,结果集是第一页,因此,指向下一页的链接(next键)是http://localhost:8000/games/?limit=10&offset=10。我们将在响应头中收到200 OK状态码,并在results数组中收到前10个游戏。以下行显示了头信息和输出内容的第一行:
HTTP/1.0 200 OK
Allow: GET, POST, HEAD, OPTIONS
Content-Type: application/json
Date: Fri, 01 Jul 2016 16:34:01 GMT
Server: WSGIServer/0.2 CPython/3.5.1
Vary: Accept, Cookie
X-Frame-Options: SAMEORIGIN
{
"count": 12,
"next": "http://localhost:8000/games/?limit=10&offset=10",
"previous": null,
"results": [
{
提示
配置最大限制是一个良好的实践,以避免生成巨大的响应。
打开网页浏览器并输入 http://localhost:8000/games/。如果您使用另一台计算机或设备运行浏览器,请将 localhost 替换为运行 Django 开发服务器的计算机的 IP 地址。可浏览的 API 将会组成并发送一个 GET 请求到 /games/,并显示其执行结果,即头部信息和 JSON 游戏列表;由于我们已配置分页,渲染的网页将包括与我们所使用的基分页类关联的默认分页模板,并在网页右上角显示可用的页码。以下截图显示了在网页浏览器中输入 URL 后渲染的网页,其中包含资源描述、游戏列表和三个页面。
理解认证、权限和限制
我们当前版本的 API 处理所有传入的请求,而不需要任何类型的认证。Django REST 框架允许我们轻松地使用不同的认证方案来识别发起请求的用户或签名请求的令牌。然后,我们可以使用这些凭证来应用权限和限制策略,这将决定请求是否必须被允许。
与其他配置类似,我们可以全局设置认证方案,并在必要时在基于类的视图或函数视图中覆盖它们。一个类列表指定了认证方案。Django REST 框架将在运行视图代码之前使用列表中指定的所有类来认证请求。如果指定了多个类,则列表中第一个成功认证的类将负责设置以下两个属性的值:
-
request.user: 用户模型实例。在我们的示例中,我们将使用django.contrib.auth.User类的实例,即 Django 的User实例。 -
request.auth: 额外的认证信息,例如认证令牌。
在成功认证后,我们可以在接收 request 参数的类视图方法中使用 request.user 属性来检索关于发起请求的用户的额外信息。
Django REST 框架在 rest_framework.authentication 模块中提供了以下三个认证类。它们都是 BaseAuthentication 类的子类:
-
BasicAuthentication: 提供基于用户名和密码的 HTTP Basic 认证。如果我们用于生产,我们必须确保 API 只通过 HTTPS 提供访问。 -
SessionAuthentication: 与 Django 的会话框架一起用于认证。 -
TokenAuthentication:提供基于简单令牌的身份验证。请求必须包含在AuthorizationHTTP 头中为用户生成的令牌,令牌前缀为"Token "。
首先,我们将使用 BasicAuthentication 和 SessionAuthentication 的组合。我们还可以稍后利用 TokenAuthentication 类。确保您已退出 Django 的开发服务器。请记住,您只需在终端或命令提示符窗口中按 Ctrl + C 即可。
打开 gamesapi/settings.py 文件,并将高亮显示的行添加到名为 REST_FRAMEWORK 的字典中,以键值对的形式配置全局默认身份验证类。示例代码文件包含在 restful_python_chapter_03_04 文件夹中,如下所示:
REST_FRAMEWORK = {
'DEFAULT_PAGINATION_CLASS':
'games.pagination.LimitOffsetPaginationWithMaxLimit',
'PAGE_SIZE': 5,
'DEFAULT_AUTHENTICATION_CLASSES': (
'rest_framework.authentication.BasicAuthentication',
'rest_framework.authentication.SessionAuthentication',
)
}
DEFAULT_AUTHENTICATION_CLASSES 设置键的值指定了一个全局设置,它是一个字符串元组,其值指示我们想要用于身份验证的类。
权限使用 request.user 和 request.auth 属性中包含的认证信息来确定是否应授予或拒绝请求访问。权限允许我们控制哪些用户类别将被授予或拒绝访问我们 API 的不同功能或部分。
例如,我们将使用 Django REST framework 中的权限功能,允许经过身份验证的用户创建游戏。未经身份验证的用户将仅被允许对游戏进行只读访问。只有创建游戏的用户才能对其进行更改,因此,我们将在我们的 API 中进行必要的更改,使游戏具有所有者用户。我们将使用预定义的权限类和自定义权限类来定义所解释的权限策略。
限制也决定了请求是否必须被授权。限制控制用户对我们 API 发出请求的速率。例如,我们希望限制未经身份验证的用户每小时最多请求 5 次。我们希望限制经过身份验证的用户每天对游戏相关视图的请求最多为 20 次。
将安全相关数据添加到模型中
我们将把一个游戏与创建者或所有者关联起来。只有经过身份验证的用户才能创建新游戏。只有游戏的创建者才能更新或删除它。所有未经身份验证的请求都只能对游戏进行只读访问。
打开 games/models.py 文件,并用以下代码替换声明 Game 类的代码。代码列表中更改的行被高亮显示。示例代码文件包含在 restful_python_chapter_03_04 文件夹中。
class Game(models.Model):
owner = models.ForeignKey(
'auth.User',
related_name='games',
on_delete=models.CASCADE)
created = models.DateTimeField(auto_now_add=True)
name = models.CharField(max_length=200, unique=True)
game_category = models.ForeignKey(
GameCategory,
related_name='games',
on_delete=models.CASCADE)
release_date = models.DateTimeField()
played = models.BooleanField(default=False)
class Meta:
ordering = ('name',)
def __str__(self):
return self.name
Game 模型声明了一个新的 owner 字段,该字段使用 django.db.models.ForeignKey 类来提供与 auth.User 模型的多对一关系,具体来说,是与 django.contrib.auth.User 模型。这个 User 模型代表 Django 认证系统中的用户。为 related_name 参数指定的 'games' 值创建了一个从 User 模型到 Game 模型的反向关系。这个值表示从相关的 User 对象回指到 Game 对象时要使用的名称。这样,我们将能够访问特定用户拥有的所有游戏。每当我们要删除一个用户时,我们希望删除该用户拥有的所有游戏,因此,我们为 on_delete 参数指定了 models.CASCADE 值。
现在,我们将运行 manage.py 的 createsuperuser 子命令来创建 Django 的 superuser,我们将使用它来轻松地验证我们的请求。我们将在稍后创建更多用户:
python manage.py createsuperuser
命令将要求你输入用于 superuser 的 username。输入你想要的用户名并按 Enter。在这个例子中,我们将使用 superuser 作为用户名。你将看到类似以下的一行:
Username (leave blank to use 'gaston'):
然后,命令将要求你输入电子邮件地址。输入一个电子邮件地址并按 Enter:
Email address:
最后,命令将要求你输入新超级用户的密码。输入你想要的密码并按 Enter。
Password:
命令将要求你再次输入密码。输入它并按 Enter。如果输入的两个密码匹配,超级用户将被创建:
Password (again):
Superuser created successfully.
现在,转到 gamesapi/games 文件夹并打开 serializers.py 文件。在声明导入的最后一行之后,在 GameCategorySerializer 类声明之前添加以下代码。示例代码文件包含在 restful_python_chapter_03_04 文件夹中:
from django.contrib.auth.models import User
class UserGameSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = Game
fields = (
'url',
'name')
class UserSerializer(serializers.HyperlinkedModelSerializer):
games = UserGameSerializer(many=True, read_only=True)
class Meta:
model = User
fields = (
'url',
'pk',
'username',
'games')
UserGameSerializer 类是 HyperlinkedModelSerializer 类的子类。我们使用这个新的序列化器类来序列化与用户相关的游戏。这个类声明了一个 Meta 内部类,该类声明了两个属性:model 和 fields。model 属性指定了与序列化器相关的模型,即 Game 类。fields 属性指定了一个字符串值的元组,其值表示我们想要在序列化中包含的相关模型的字段名称。我们只想包含 URL 和游戏名称,因此,代码将 'url' 和 'name' 作为元组的成员。我们不想使用 GameSerializer 序列化器类来序列化与用户相关的游戏,因为我们想序列化更少的字段,因此,我们创建了 UserGameSerializer 类。
UserSerializer类是HyperlinkedModelSerializer类的子类。这个类声明了一个Meta内部类,该类声明了两个属性-model和fields。model属性指定了与序列化器相关的模型,即django.contrib.auth.models.User类。
UserSerializer类声明了一个games属性,它是一个之前解释过的UserGameSerializer的实例,其中many和read_only等于True,因为这是一个一对多关系,并且是只读的。我们使用games名称,我们在将owner字段作为models.ForeignKey实例添加到Game模型时指定的related_name字符串值。这样,games字段将为我们提供属于用户的每个游戏的 URL 和名称数组。
我们将对gamesapi/games文件夹中的serializers.py文件进行更多修改。我们将在现有的GameSerializer类中添加一个owner字段。以下行显示了GameSerializer类的新代码。新行被突出显示。示例的代码文件包含在restful_python_chapter_03_04文件夹中:
class GameSerializer(serializers.HyperlinkedModelSerializer):
# We just want to display the owner username (read-only)
owner = serializers.ReadOnlyField(source='owner.username')
# We want to display the game cagory's name instead of the id
game_category = serializers.SlugRelatedField(queryset=GameCategory.objects.all(), slug_field='name')
class Meta:
model = Game
depth = 4
fields = (
'url',
'owner',
'game_category',
'name',
'release_date',
'played')
现在,GameSerializer类声明了一个owner属性,它是一个serializers.ReadOnlyField的实例,其source等于'owner.username'。这样,我们将序列化相关django.contrib.auth.User类中owner字段持有的username字段的值。我们使用ReadOnlyField是因为当认证用户创建游戏时,所有者会自动填充,因此,在创建游戏后不可能更改所有者。这样,owner字段将为我们提供创建游戏的用户名。此外,我们还向字段字符串元组中添加了'owner'。
为对象级权限创建自定义权限类
在games文件夹中创建一个名为permissions.py的新 Python 文件,并输入以下代码,该代码声明了新的IsOwnerOrReadOnly类。示例的代码文件包含在restful_python_chapter_03_04文件夹中:
from rest_framework import permissions
class IsOwnerOrReadOnly(permissions.BasePermission):
def has_object_permission(self, request, view, obj):
if request.method in permissions.SAFE_METHODS:
return True
else:
return obj.owner == request.user
rest_framework.permissions.BasePermission类是所有权限类应该继承的基础类。前面的行声明了IsOwnerOrReadOnly类作为BasePermission类的子类,并覆盖了在超类中定义的has_object_permission方法,该方法返回一个bool值,指示是否应该授予权限。如果请求中指定的 HTTP 动词(request.method)是permission.SAFE_METHODS(GET、HEAD或OPTIONS)中指定的三个安全方法之一,则has_object_permission方法返回True并授予请求权限。这些 HTTP 动词不会更改相关资源,因此它们包含在字符串元组permissions.SAFE_METHODS中。
如果请求中指定的 HTTP 动词(request.method)不是三种安全方法中的任何一种,代码返回True,并且只有当接收到的obj(obj.owner)的owner属性与创建请求的用户(request.user)匹配时,才授予权限。这样,只有相关资源的所有者才会被授予包含非安全 HTTP 动词的请求的权限。
我们将使用新的IsOwnerOrReadOnly权限类来确保只有游戏的所有者才能修改现有的游戏。我们将此权限类与rest_framework.permissions.IsAuthenticatedOrReadOnly权限类结合使用,后者在请求未以用户身份认证时只允许对资源进行只读访问。
持续请求的用户
我们希望能够列出所有用户并检索单个用户的详细信息。我们将创建rest_framework.generics中声明的两个通用类视图的子类:
-
ListAPIView:实现get方法,用于检索查询集的列表 -
RetrieveAPIView:实现get方法,用于检索模型实例
前往gamesapi/games文件夹,打开views.py文件。在声明导入的最后一行之后,在GameCategoryList类声明之前添加以下代码。示例的代码文件包含在restful_python_chapter_03_04文件夹中:
from django.contrib.auth.models import User
from games.serializers import UserSerializer
from rest_framework import permissions
from games.permissions import IsOwnerOrReadOnly
class UserList(generics.ListAPIView):
queryset = User.objects.all()
serializer_class = UserSerializer
name = 'user-list'
class UserDetail(generics.RetrieveAPIView):
queryset = User.objects.all()
serializer_class = UserSerializer
name = 'user-detail'
将以下高亮显示的行添加到views.py文件中声明的ApiRoot类。现在,我们能够通过可浏览的 API 导航到与用户相关的视图。示例的代码文件包含在restful_python_chapter_03_04文件夹中。
class ApiRoot(generics.GenericAPIView):
name = 'api-root'
def get(self, request, *args, **kwargs):
return Response({
'players': reverse(PlayerList.name, request=request),
'game-categories': reverse(GameCategoryList.name, request=request),
'games': reverse(GameList.name, request=request),
'scores': reverse(PlayerScoreList.name, request=request),
'users': reverse(UserList.name, request=request),
})
前往gamesapi/games文件夹,打开urls.py文件。将以下元素添加到urlpatterns字符串列表中。新字符串定义了 URL 模式,该模式指定了请求中必须匹配的正则表达式,以运行在views.py文件中创建的基于类的视图的特定方法:UserList和UserDetail。示例的代码文件包含在restful_python_chapter_03_04文件夹中:
url(r'^users/$',
views.UserList.as_view(),
name=views.UserList.name),
url(r'^users/(?P<pk>[0-9]+)/$',
views.UserDetail.as_view(),
name=views.UserDetail.name),
我们必须在gamesapi文件夹中的urls.py文件中添加一行,具体来说,是gamesapi/urls.py文件。该文件定义了根 URL 配置,我们希望包含 URL 模式以允许可浏览的 API 显示登录和注销视图。以下是在gamesapi/urls.py文件中的新代码行,新行已高亮显示。示例的代码文件包含在restful_python_chapter_03_04文件夹中:
from django.conf.urls import url, include
urlpatterns = [
url(r'^', include('games.urls')),
url(r'^api-auth/', include('rest_framework.urls'))
]
我们需要对GameList类视图进行修改。我们将重写perform_create方法,在将新的Game实例持久化到数据库之前,先填充owner字段。以下是在views.py文件中GameList类的新的代码行,新行已高亮显示。示例的代码文件包含在restful_python_chapter_03_04文件夹中:
class GameList(generics.ListCreateAPIView):
queryset = Game.objects.all()
serializer_class = GameSerializer
name = 'game-list'
def perform_create(self, serializer):
# Pass an additional owner field to the create method
# To Set the owner to the user received in the request
serializer.save(owner=self.request.user)
GameList 类从 rest_framework.mixins.CreateModelMixin 类继承了 perform_create 方法。请记住,generics.ListCreateAPIView 类也继承了 CreateModelMixin 类和其他类。重写的 perform_create 方法的代码通过为 serializer.save 方法的 owner 参数设置值,将额外的 owner 字段传递给创建方法。代码将所有者属性设置为 self.request.user 的值,即与请求关联的用户。这样,每当持久化一个新的游戏时,它都会将请求关联的用户保存为其所有者。
配置权限策略
现在,我们将为与游戏相关的基于类的视图配置权限策略。我们将覆盖 GameList 和 GameDetail 类的 permission_classes 类属性值。
以下行显示了 views.py 文件中 GameList 类的新代码。新行被突出显示。请不要删除为此类添加的 perform_create 方法的代码。示例的代码文件包含在 restful_python_chapter_03_04 文件夹中:
class GameList(generics.ListCreateAPIView):
queryset = Game.objects.all()
serializer_class = GameSerializer
name = 'game-list'
permission_classes = (
permissions.IsAuthenticatedOrReadOnly,
IsOwnerOrReadOnly,
)
以下行显示了 views.py 文件中 GameDetail 类的新代码。新行被突出显示。请不要删除为此类添加的 perform_create 方法的代码。示例的代码文件包含在 restful_python_chapter_03_04 文件夹中:
class GameDetail(generics.RetrieveUpdateDestroyAPIView):
queryset = Game.objects.all()
serializer_class = GameSerializer
name = 'game-detail'
permission_classes = (
permissions.IsAuthenticatedOrReadOnly,
IsOwnerOrReadOnly)
我们在两个类中添加了相同的行。我们在 permission_classes 元组中包含了 IsAuthenticatedOrReadOnly 类和之前创建的 IsOwnerOrReadOnly 权限类。
在迁移中为新的必填字段设置默认值
我们已经在数据库中持久化了许多游戏,并为游戏添加了一个新的必填字段 owner。我们不想删除所有现有游戏,因此我们将利用 Django 中的一些功能,这些功能使我们能够在不丢失现有数据的情况下轻松地在底层数据库中做出更改。
现在,我们需要检索我们创建的 superuser 的 id,以便将其用作现有游戏的默认所有者。Django 将允许我们轻松地更新现有游戏,为它们设置所有者用户。
运行以下命令以从 auth_user 表中检索 id,该行的用户名为 'superuser'。将 superuser 替换为之前创建的超级用户的用户名。此外,将命令中的 user_name 替换为您用于创建 PostgreSQL 数据库的用户名,将 password 替换为您为该数据库用户选择的密码。该命令假设您在运行命令的同一台计算机上运行 PostgreSQL。如果您正在使用 SQLite 数据库,您可以在 PostgreSQL 命令行或基于 GUI 的工具中运行等效命令以执行相同的查询。
psql --username=user_name --dbname=games --command="SELECT id FROM auth_user WHERE username = 'superuser';"
以下行显示了带有 id 值的输出:1
id
----
1
(1 row)
现在,运行以下 Python 脚本来生成迁移,这将允许我们同步数据库与添加到 Game 模型的新字段:
python manage.py makemigrations games
Django 将显示以下问题:
You are trying to add a non-nullable field 'owner' to game without a default; we can't do that (the database needs something to populate existing rows).
Please select a fix:
1) Provide a one-off default now (will be set on all existing rows)
2) Quit, and let me add a default in models.py
Select an option:
我们希望提供一个一次性默认值,该值将设置在所有现有行上,因此输入 1 以选择第一个选项并按 Enter 键。
Django 将显示以下文本,要求我们输入默认值:
Please enter the default value now, as valid Python
The datetime and django.utils.timezone modules are available, so you can do e.g. timezone.now()
>>>
输入之前检索到的 id 值,在我们的例子中是 1,然后按 Enter。以下行显示了运行前面命令后生成的输出:
Migrations for 'games':
0003_game_owner.py:
- Add field owner to game
输出表明 gamesapi/games/migrations/0003_game_owner.py 文件包含了将名为 owner 的字段添加到 game 的代码。以下行显示了由 Django 自动生成的此文件的代码。示例代码文件包含在 restful_python_chapter_03_04 文件夹中:
# -*- coding: utf-8 -*-
# Generated by Django 1.9.7 on 2016-07-01 21:06
from __future__ import unicode_literals
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('games', '0002_auto_20160623_2131'),
]
operations = [
migrations.AddField(
model_name='game',
name='owner',
field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, related_name='games', to=settings.AUTH_USER_MODEL),
preserve_default=False,
),
]
代码声明了一个名为 Migration 的 django.db.migrations.Migration 类的子类,该类定义了一个包含 migrations.AddField 的 operations 列表,该列表将添加所有者字段到与 game 模型相关的表中。
现在,运行以下 Python 脚本来应用所有生成的迁移并执行数据库表中的更改:
python manage.py migrate
以下行显示了运行前面命令后生成的输出。请注意,迁移的顺序可能因你的配置而异:
Operations to perform:
Apply all migrations: admin, auth, contenttypes, games, sessions
Running migrations:
Rendering model states... DONE
Applying games.0003_game_owner... OK
在我们运行前面的命令后,PostgreSQL 数据库中的 games_game 表将新增一个 owner_id 字段。games_game 表中现有的行将使用 Django 指示我们用于新 owner_id 字段的默认值。我们可以使用 PostgreSQL 命令行或任何其他允许我们轻松检查 PostgreSQL 数据库内容的应用程序来检查 Django 更新的 games_game 表。如果你决定继续使用 SQLite,请使用与此数据库相关的命令或工具。
运行以下命令以启动交互式外壳。确保你在终端或命令提示符中的 gamesapi 文件夹内:
python manage.py shell
你会注意到在介绍默认 Python 交互式外壳的常规行之后,会显示一行写着(InteractiveConsole)。在 Python 交互式环境中输入以下代码以创建另一个非超级用户。我们将使用此用户和超级用户来测试我们的权限策略更改。示例代码文件包含在 restful_python_chapter_03_04 文件夹中的 users_test_01.py 文件中。
你可以将 kevin 替换为你想要的用户名,将 kevin@eaxmple.com 替换为电子邮件,将 kevinpassword 替换为你想要为此用户使用的密码。然而,请注意,我们将在以下部分使用这些凭据。确保你始终使用你自己的凭据替换这些凭据:
from django.contrib.auth.models import User
user = User.objects.create_user('kevin', 'kevin@example.com', 'kevinpassword')
user.save()
最后,通过输入以下命令退出交互式控制台:
quit()
现在,我们可以启动 Django 的开发服务器来编写并发送 HTTP 请求。根据您的需求执行以下两个命令之一以访问连接到您的局域网的其他设备或计算机上的 API。请记住,我们在 第一章 中分析了它们之间的区别,使用 Django 开发 RESTful API:
python manage.py runserver
python manage.py runserver 0.0.0.0:8000
在运行上述任何命令之后,开发服务器将开始监听端口 8000。
编写带有必要身份验证的请求
现在,我们将编写并发送一个不包含身份验证凭据的 HTTP 请求来创建一个新的游戏:
http POST :8000/games/ name='The Last of Us' game_category='3D RPG' played=false release_date='2016-06-21T03:02:00.776594Z'
以下是对应的 curl 命令:
curl -iX POST -H "Content-Type: application/json" -d '{"name":"The Last of Us", "game_category":"3D RPG", "played": "false", "release_date": "2016-06-21T03:02:00.776594Z"}' :8000/games/
我们将在响应头中收到一个 401 未授权 状态码和一个详细消息,表明我们没有在 JSON 主体中提供身份验证凭据。以下是一些示例响应的行:
HTTP/1.0 401 Unauthorized
Allow: GET, POST, HEAD, OPTIONS
Content-Type: application/json
Date: Sun, 03 Jul 2016 22:23:07 GMT
Server: WSGIServer/0.2 CPython/3.5.1
Vary: Accept, Cookie
WWW-Authenticate: Basic realm="api"
X-Frame-Options: SAMEORIGIN
{
"detail": "Authentication credentials were not provided."
}
如果我们想要创建一个新的游戏,即向 /games/ 发送一个 POST 请求,我们需要使用 HTTP 身份验证提供身份验证凭据。现在,我们将编写并发送一个包含身份验证凭据的 HTTP 请求来创建一个新的游戏,即使用 superuser 名称和他的密码。请记住将 superuser 替换为你为 superuser 使用的名称,将 password 替换为你为该用户配置的密码:
http -a superuser:'password' POST :8000/games/ name='The Last of Us' game_category='3D RPG' played=false release_date='2016-06-21T03:02:00.776594Z'
以下是对应的 curl 命令:
curl --user superuser:'password' -iX POST -H "Content-Type: application/json" -d '{"name":"The Last of Us", "game_category":"3D RPG", "played": "false", "release_date": "2016-06-21T03:02:00.776594Z"}' :8000/games/
如果以 superuser 用户作为其拥有者的新 Game 对象成功持久化到数据库中,函数将返回一个 HTTP 201 已创建 状态码,并将最近持久化的 Game 对象序列化为 JSON 格式在响应体中。以下是一些示例响应的行,其中包含 JSON 响应中的新 Game 对象:
HTTP/1.0 201 Created
Allow: GET, POST, HEAD, OPTIONS
Content-Type: application/json
Date: Mon, 04 Jul 2016 02:45:36 GMT
Location: http://localhost:8000/games/16/
Server: WSGIServer/0.2 CPython/3.5.1
Vary: Accept
X-Frame-Options: SAMEORIGIN
{
"game_category": "3D RPG",
"name": "The Last of Us",
"owner": "superuser",
"played": false,
"release_date": "2016-06-21T03:02:00.776594Z",
"url": "http://localhost:8000/games/16/"
}
现在,我们将编写并发送一个包含身份验证凭据的 HTTP 请求来更新之前创建的游戏的 played 字段值。然而,在这种情况下,我们将使用在 Django 中创建的另一个用户来对请求进行身份验证。请记住将 kevin 替换为您为该用户使用的名称,将 kevinpassword 替换为您为该用户配置的密码。此外,将 16 替换为您在配置中为之前创建的游戏生成的 id。我们将使用 PATCH 方法。
http -a kevin:'kevinpassword' PATCH :8000/games/16/ played=true
以下是对应的 curl 命令:
curl --user kevin:'kevinpassword' -iX PATCH -H "Content-Type: application/json" -d '{"played": "true"}' :8000/games/16/
我们将在响应头中收到一个 403 禁止 状态码和一个详细消息,表明我们没有权限在 JSON 主体中执行该操作。我们想要更新的游戏的拥有者是 superuser,而此请求的身份验证凭据使用的是不同的用户。因此,操作被 IsOwnerOrReadOnly 类中的 has_object_permission 方法拒绝。以下是一些示例响应的行:
HTTP/1.0 403 Forbidden
Allow: GET, PUT, PATCH, DELETE, HEAD, OPTIONS
Content-Type: application/json
Date: Mon, 04 Jul 2016 02:59:15 GMT
Server: WSGIServer/0.2 CPython/3.5.1
Vary: Accept
X-Frame-Options: SAMEORIGIN
{
"detail": "You do not have permission to perform this action."
}
如果我们使用相同的认证凭据,通过GET方法向同一资源发送 HTTP 请求,我们将能够检索到指定用户不拥有的游戏。这将会成功,因为GET是安全方法之一,非所有者用户被允许读取资源。请记住将kevin替换为你为用户使用的名称,将kevinpassword替换为你为该用户配置的密码。此外,将16替换为你配置中为之前创建的游戏生成的 ID:
http -a kevin:'kevinpassword' GET :8000/games/16/
以下是对应的curl命令:
curl --user kevin:'kevinpassword' -iX GET :8000/games/16/
使用认证凭据浏览 API
打开一个网络浏览器,输入http://localhost:8000/。如果你使用另一台计算机或设备运行浏览器,请将 localhost 替换为运行 Django 开发服务器的计算机的 IP 地址。可浏览 API 将组成并发送一个GET请求到/,并显示其执行结果,即 API 根目录。你会注意到右上角有一个登录超链接。
点击“登录”,浏览器将显示 Django REST 框架的登录页面。在用户名中输入kevin,在密码中输入kevinpassword,然后点击登录。请记住将kevin替换为你为用户使用的名称,将kevinpassword替换为你为该用户配置的密码。现在,你将作为kevin登录,并且你通过可浏览 API 编写的所有请求都将使用此用户。你将被重定向回API 根目录,你会注意到登录超链接被用户名(kevin)和一个允许你注销的下拉菜单所替换。以下截图显示了登录为kevin后的 API 根目录。
点击或轻触用户右侧的 URL。如果你在本地主机上浏览,URL 将是http://localhost:8000/users/。可浏览 API 将渲染用户列表的网页。以下行显示了GET请求到localhost:8000/users/的结果的 JSON 体。
games数组包含了用户拥有的每个游戏的 URL 和名称,因为UserGameSerializer类正在为每个游戏序列化内容:
HTTP 200 OK
Allow: GET, HEAD, OPTIONS
Content-Type: application/json
Vary: Accept
{
"count": 2,
"next": null,
"previous": null,
"results": [
{
"url": "http://localhost:8000/users/1/",
"pk": 1,
"username": "superuser",
"games": [
{
"url": "http://localhost:8000/games/10/",
"name": "A Dark Room"
},
{
"url": "http://localhost:8000/games/11/",
"name": "Bastion"
},
...
]
},
{
"url": "http://localhost:8000/users/3/",
"pk": 3,
"username": "kevin",
"games": []
}
]
}
点击或轻触列表中列出的由superuser用户拥有的游戏之一的 URL。可浏览 API 将渲染游戏详情的网页。点击或轻触OPTIONS,DELETE按钮将出现。点击或轻触DELETE。网络浏览器将显示一个确认对话框。点击或轻触DELETE。我们将在响应头中收到403 Forbidden状态码,并在 JSON 体中收到一条表示我们没有权限执行该操作的详细消息。
我们想要删除的游戏的所有者是superuser,而这个请求的身份验证凭据使用了一个不同的用户,具体是kevin。因此,操作被IsOwnerOrReadOnly类中的has_object_permission方法拒绝。以下截图显示了示例响应:

小贴士
我们还可以利用 Django REST Framework 为我们提供的其他身份验证插件。您可以在www.django-rest-framework.org/api-guide/authentication/了解更多关于框架为我们提供的所有身份验证可能性。
测试你的知识
-
对于现有资源更新单个字段,最合适的 HTTP 方法是什么:
-
PUT -
POST -
PATCH
-
-
以下哪个分页类在 Django REST Framework 中提供了基于限制/偏移的样式:
-
rest_framework.pagination.LimitOffsetPagination -
rest_framework.pagination.LimitOffsetPaging -
rest_framework.styles.LimitOffsetPagination
-
-
rest_framework.authentication.BasicAuthentication类:-
与 Django 的会话框架一起用于身份验证。
-
提供基于用户名和密码的 HTTP 基本身份验证。
-
提供基于简单令牌的身份验证。
-
-
rest_framework.authentication.SessionAuthentication类:-
与 Django 的会话框架一起用于身份验证。
-
提供基于用户名和密码的 HTTP 基本身份验证。
-
提供基于简单令牌的身份验证。
-
-
以下哪个设置键的值指定了一个全局设置,该设置是一个字符串值的元组,表示我们想要用于身份验证的类:
-
DEFAULT_AUTH_CLASSES -
AUTHENTICATION_CLASSES -
DEFAULT_AUTHENTICATION_CLASSES
-
摘要
在本章中,我们从多个方面改进了 REST API。我们向模型添加了唯一约束并更新了数据库,我们使使用PATCH方法更新单个字段变得容易,并且我们利用了分页。
然后,我们开始处理身份验证、权限和节流。我们将与模型相关的安全数据添加到数据库中,并进行了更新。我们在不同的代码片段中进行了多次更改,以实现特定的安全目标,并利用了 Django REST Framework 的身份验证和权限功能。
现在我们已经构建了一个改进且复杂的 API,它考虑了身份验证并使用了权限策略,我们将使用框架中包含的额外抽象,我们将添加节流和测试,这就是我们将在下一章中讨论的内容。
第四章:使用 Django 限制、过滤、测试和部署 API
在本章中,我们将使用 Django 和 Django REST Framework 中包含的附加功能来改进我们的 RESTful API。我们还将编写和执行单元测试,并学习一些与部署相关的内容。本章将涵盖以下主题:
-
理解限制类
-
配置限制策略
-
测试限制策略
-
理解过滤、搜索和排序类
-
为视图配置过滤、搜索和排序
-
测试过滤、搜索和排序功能
-
在可浏览的 API 中进行过滤、搜索和排序
-
编写第一轮单元测试
-
运行单元测试并检查测试覆盖率
-
提高测试覆盖率
-
理解部署和可扩展性的策略
理解限制类
到目前为止,我们还没有对我们的 API 使用设置任何限制,因此,认证用户和非认证用户都可以随意组合并发送尽可能多的请求。我们只是利用了 Django REST Framework 中可用的分页功能来指定我们希望将大量结果集拆分为单独的数据页面的方式。然而,任何用户都可以发送成千上万的请求进行处理,而没有任何限制。
我们将使用限制来配置以下 API 使用的限制:
-
非认证用户:每小时最多 5 个请求。
-
认证用户:每小时最多 20 个请求。
此外,我们希望配置每小时最多 100 个请求到游戏类别相关视图,无论用户是否认证。
Django REST Framework 在rest_framework.throttling模块中提供了以下三个限制类。它们都是SimpleRateThrottle类的子类,而SimpleRateThrottle类又是BaseThrottle类的子类。这些类允许我们根据不同的机制设置每个周期内最大请求数量,这些机制基于用于指定范围的前一个请求信息。限制的前一个请求信息存储在缓存中,并且这些类覆盖了get_cache_key方法,该方法确定范围。
-
AnonRateThrottle:此类限制了匿名用户可以发起的请求数量。请求的 IP 地址是唯一的缓存键,因此,来自同一 IP 地址的所有请求将累积总请求数量。 -
UserRateThrottle:此类限制了特定用户发起请求的速度。对于认证用户,认证用户 ID 是唯一的缓存键。对于匿名用户,请求的 IP 地址是唯一的缓存键。 -
ScopedRateThrottle:此类限制了与throttle_scope属性分配的值标识的 API 特定部分的请求速率。当我们需要以不同的速率限制对 API 特定部分的访问时,此类非常有用。
配置速率限制策略
我们将使用前面讨论的三个速率限制类的组合,以实现我们之前解释的目标。确保您退出 Django 的开发服务器。请记住,您只需在运行它的终端或命令提示符窗口中按Ctrl + C即可。
打开gamesapi/settings.py文件,并将高亮显示的行添加到名为REST_FRAMEWORK的字典中,包含两个键值对,用于配置全局默认的速率限制类及其速率。示例代码文件包含在restful_python_chapter_04_01文件夹中:
REST_FRAMEWORK = {
'DEFAULT_PAGINATION_CLASS':
'games.pagination.LimitOffsetPaginationWithMaxLimit',
'PAGE_SIZE': 5,
'DEFAULT_AUTHENTICATION_CLASSES': (
'rest_framework.authentication.BasicAuthentication',
'rest_framework.authentication.SessionAuthentication',
),
'DEFAULT_THROTTLE_CLASSES': (
'rest_framework.throttling.AnonRateThrottle',
'rest_framework.throttling.UserRateThrottle',
),
'DEFAULT_THROTTLE_RATES': {
'anon': '5/hour',
'user': '20/hour',
'game-categories': '30/hour',
}
}
DEFAULT_THROTTLE_CLASSES设置键的值指定了一个全局设置,是一个字符串值的元组,表示我们想要用于速率限制的默认类-AnonRateThrottle和UserRateThrottle。DEFAULT_THROTTLE_RATES设置键指定了一个包含默认速率限制的字典。对于'anon'键指定的值表示我们希望匿名用户每小时最多有五个请求。对于'user'键指定的值表示我们希望认证用户每小时最多有 20 个请求。对于'game-categories'键指定的值表示我们希望该名称的作用域每小时最多有 30 个请求。
最大速率是一个字符串,指定了每期的请求数量,格式如下:'number_of_requests/period',其中period可以是以下任何一个:
-
s: 秒 -
sec: 秒 -
m: 分钟 -
min: 分钟 -
h: 小时 -
hour: 小时 -
d: 天 -
day: 天
现在,我们将为与游戏类别相关的基于类的视图配置速率限制策略。我们将覆盖GameCategoryList和GameCategoryDetail类的throttle_scope和throttle_classes类属性。首先,我们必须在views.py文件中的最后一个导入语句之后添加以下import语句。示例代码文件包含在restful_python_chapter_04_01文件夹中:
from rest_framework.throttling import ScopedRateThrottle
以下行显示了views.py文件中GameCategoryList类的新代码。以下代码中的新行被高亮显示。示例代码文件包含在restful_python_chapter_04_01文件夹中:
class GameCategoryList(generics.ListCreateAPIView):
queryset = GameCategory.objects.all()
serializer_class = GameCategorySerializer
name = 'gamecategory-list'
throttle_scope = 'game-categories'
throttle_classes = (ScopedRateThrottle,)
以下行显示了views.py文件中GameCategoryDetail类的新代码。以下代码中的新行被高亮显示。示例代码文件包含在restful_python_chapter_04_01文件夹中:
class GameCategoryDetail(generics.RetrieveUpdateDestroyAPIView):
queryset = GameCategory.objects.all()
serializer_class = GameCategorySerializer
name = 'gamecategory-detail'
throttle_scope = 'game-categories'
throttle_classes = (ScopedRateThrottle,)
我们在两个类中添加了相同的行。我们将'game-categories'设置为throttle_scope类属性的值,并将ScopedRateThrottle包含在定义throttle_classes值的元组中。这样,两个基于类的视图将使用为'game-categories'作用域指定的设置和ScopeRateThrottle类进行节流。这些视图每小时将能够服务 30 次请求,并且不会考虑适用于我们用于节流的默认类的全局设置:AnonRateThrottle和UserRateThrottle。
在 Django 运行视图的主体之前,它会为在节流类中指定的每个节流类执行检查。在游戏类别相关的视图中,我们编写了覆盖默认设置的代码。如果单个节流检查失败,代码将引发一个Throttled异常,Django 将不会执行视图的主体。缓存负责存储之前请求的信息以供节流检查。
测试节流策略
现在,我们可以启动 Django 的开发服务器来组合和发送 HTTP 请求。根据您的需求,执行以下两个命令之一以访问连接到您的局域网的其他设备或计算机上的 API。请记住,我们在第一章 《使用 Django 开发 RESTful API》中分析了它们之间的区别。
python manage.py runserver
python manage.py runserver 0.0.0.0:8000
在我们运行任何之前的命令后,开发服务器将开始监听端口8000。
现在,我们将六次发送一个不包含认证凭据的 HTTP 请求来检索所有玩家的分数:
http :8000/player-scores/
我们还可以使用 macOS 或 Linux 中 shell 的功能,通过单行命令运行之前的命令六次。我们也可以在 Windows 的 Cygwin 终端中运行该命令。我们可以在 bash shell 中执行下一行。然而,我们将依次看到所有结果,您需要滚动以了解每次执行的情况:
for i in {1..6}; do http :8000/player-scores/; done;
以下是我们必须执行六次的等效 curl 命令:
curl -iX GET :8000/player-scores/
以下是在 macOS 或 Linux 的 bash shell 中,或 Windows 的 Cygwin 终端中,通过单行执行六次的一个等效 curl 命令:
for i in {1..6}; do curl -iX GET :8000/player-scores/; done;
Django 不会处理第六个请求,因为AnonRateThrottle被配置为默认节流类之一,并且其节流设置指定每小时五次请求。因此,我们将在响应头中收到一个429 Too many requests状态码,以及一个指示请求被节流和服务器将能够处理额外请求的时间的消息。响应头中的Retry-After键提供了等待下一次请求所需的秒数:3189。以下行显示了一个示例响应:
HTTP/1.0 429 Too Many Requests
Allow: GET, POST, HEAD, OPTIONS
Content-Type: application/json
Date: Tue, 05 Jul 2016 03:37:50 GMT
Retry-After: 3189
Server: WSGIServer/0.2 CPython/3.5.1
Vary: Accept, Cookie
X-Frame-Options: SAMEORIGIN
{
"detail": "Request was throttled. Expected available in 3189 seconds."
}
现在,我们将发送一个包含认证凭据的 HTTP 请求来检索玩家的分数,即使用 superuser 用户名和他的密码。我们将执行相同的请求六次。请记住将 superuser 替换为你用于 superuser 的名称,将 password 替换为你为该用户配置的密码,在第三章 改进和添加 API 认证:
http -a superuser:'password' :8000/player-scores/
我们也可以用单行运行之前的命令六次:
for i in {1..6}; do http -a superuser:'password' :8000/player-scores/; done;
以下是我们必须执行六次的等效 curl 命令:
curl --user superuser:'password' -iX GET :8000/player-scores/
以下是一个单行执行六次的等效 curl 命令:
for i in {1..6}; do curl --user superuser:'password' -iX GET :8000/player-scores/; done;
Django 将处理第六个请求,因为我们已经用同一个用户 UserRateThrottle 组成了并发送了六个认证请求,UserRateThrottle 被配置为默认限制类之一,其限制设置指定每小时 20 个请求。
如果我们再运行之前的命令 15 次,我们将累积 21 个请求,并在响应头中收到一个 429 Too many requests 状态码,以及一条消息表明请求已被限制,并且在最后一次执行后服务器将能够处理额外请求的时间。
现在,我们将三十次发送一个不包含认证凭据的 HTTP 请求来检索所有游戏类别:
http :8000/game-categories/
我们也可以用单行运行之前的命令三十次:
for i in {1..30}; do http :8000/game-categories/; done;
以下是我们必须执行三十次的等效 curl 命令:
curl -iX GET :8000/game-categories/
以下是一个单行执行三十次的等效 curl 命令:
for i in {1..30}; do curl -iX GET :8000/game-categories/; done;
Django 将处理三十个请求,因为我们已经向一个被 'game-categories' 限制范围识别的 URL 发送了 30 个未认证的请求,该 URL 使用 ScopedRateThrottle 类进行限制权限控制。'game-categories' 限制范围的限制设置被配置为每小时 30 个请求。
如果我们再次运行之前的命令,我们将累积 31 个请求,并在响应头中收到一个 429 Too many requests 状态码,以及一条消息表明请求已被限制,并且在最后一次执行后服务器将能够处理额外请求的时间。
理解过滤、搜索和排序类
我们利用 Django REST Framework 中可用的分页功能来指定我们希望如何将大型结果集拆分为单个数据页。然而,我们一直都在使用整个查询集作为结果集。Django REST Framework 使得我们可以轻松地自定义过滤、搜索和排序功能,以适应我们已编写的视图。
首先,我们将在虚拟环境中安装django-filter包。这样,我们将能够使用 Django REST Framework 中易于定制的字段过滤功能。确保您已退出 Django 的开发服务器。请记住,您只需在运行开发服务器的终端或命令提示符窗口中按Ctrl + C即可。然后,我们只需运行以下命令来安装django-filter包:
pip install django-filter
输出的最后几行将指示django-filter包已成功安装。
Collecting django-filter
Downloading django_filter-0.13.0-py2.py3-none-any.whl
Installing collected packages: django-filter
Successfully installed django-filter-0.13.0
此外,我们将在我们的虚拟环境中安装django-cripsy-forms包。这个包增强了可浏览 API 渲染不同过滤器的方式。运行以下命令来安装django-cripsy-forms包:我们只需要运行以下命令来安装此包:
pip install django-crispy-forms
输出的最后几行将指示django-crispy-forms包已成功安装:
Collecting django-crispy-forms
Installing collected packages: django-crispy-forms
Running setup.py install for django-crispy-forms
Successfully installed django-crispy-forms-1.6.0
打开gamesapi/settings.py文件,并将突出显示的行添加到REST_FRAMEWORK字典中。示例代码文件包含在restful_python_chapter_04_02文件夹中:
REST_FRAMEWORK = {
'DEFAULT_PAGINATION_CLASS':
'games.pagination.LimitOffsetPaginationWithMaxLimit',
'PAGE_SIZE': 5,
'DEFAULT_FILTER_BACKENDS': (
'rest_framework.filters.DjangoFilterBackend',
'rest_framework.filters.SearchFilter',
'rest_framework.filters.OrderingFilter',
),
'DEFAULT_AUTHENTICATION_CLASSES': (
'rest_framework.authentication.BasicAuthentication',
'rest_framework.authentication.SessionAuthentication',
),
'DEFAULT_THROTTLE_CLASSES': (
'rest_framework.throttling.AnonRateThrottle',
'rest_framework.throttling.UserRateThrottle',
),
'DEFAULT_THROTTLE_RATES': {
'anon': '5/hour',
'user': '20/hour',
'game-categories': '30/hour',
}
}
'DEFAULT_FILTER_BACKENDS'设置键的值指定了一个全局设置,其值是一个字符串元组,表示我们想要用于过滤器后端的默认类。我们将使用以下三个类:
-
rest_framework.filters.DjangoFilterBackend: 这个类提供字段过滤功能。它使用之前安装的django-filter包。我们可以指定我们想要能够过滤的字段集,或者创建一个具有更多自定义设置的rest_framework.filters.FilterSet类,并将其与视图关联。 -
rest_framework.filters.SearchFilter: 这个类提供基于单个查询参数的搜索功能,它基于 Django 管理员的搜索功能。我们可以指定我们想要包含在搜索中的字段集,客户端将通过对这些字段进行单个查询的查询来过滤项目。当我们想要使请求能够通过单个查询搜索多个字段时,这非常有用。 -
rest_framework.filters.OrderingFilter: 这个类允许客户端通过单个查询参数来控制结果的排序。我们还可以指定可以排序的字段。
小贴士
我们还可以通过在元组中包含之前列出的任何类来配置过滤器后端,并将其分配给通用视图类的filter_backends类属性。然而,在这种情况下,我们将使用所有基于类的视图的默认配置。
将'crispy_forms'添加到settings.py文件中已安装的应用程序列表中,具体来说,添加到INSTALLED_APPS字符串列表中。以下代码显示了我们必须添加的突出显示的代码。示例代码文件包含在restful_python_chapter_04_02文件夹中:
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
# Django REST Framework
'rest_framework',
# Games application
'games.apps.GamesConfig',
# Crispy forms
'crispy_forms',
]
小贴士
我们必须小心配置可用于过滤、搜索和排序功能的字段。配置将对数据库执行的查询产生影响,因此我们必须确保我们有适当的数据库优化,考虑到将要执行的查询。
配置视图的过滤、搜索和排序
前往 gamesapi/games 文件夹并打开 views.py 文件。在声明导入的最后一行之后但在 UserList 类声明之前添加以下代码。示例代码文件包含在 restful_python_chapter_04_02 文件夹中:
from rest_framework import filters
from django_filters import NumberFilter, DateTimeFilter, AllValuesFilter
将以下加粗的行添加到在 views.py 文件中声明的 GameCategoryList 类中。示例代码文件包含在 restful_python_chapter_04_02 文件夹中:
class GameCategoryList(generics.ListCreateAPIView):
queryset = GameCategory.objects.all()
serializer_class = GameCategorySerializer
name = 'gamecategory-list'
throttle_scope = 'game-categories'
throttle_classes = (ScopedRateThrottle,)
filter_fields = ('name',)
search_fields = ('^name',)
ordering_fields = ('name',)
filter_fields 属性指定了一个字符串元组,其值表示我们希望能够过滤的字段名称。在底层,Django REST Framework 将自动创建一个 rest_framework.filters.FilterSet 类并将其关联到 GameCategoryList 视图。这样,我们就可以对 name 字段进行过滤。
search_fields 属性指定了一个字符串元组,其值表示我们希望在搜索功能中包含的文本类型字段名称。在这种情况下,我们只想针对名称字段进行搜索并执行以开头匹配。字段名称前包含的 '^' 前缀表示我们希望将搜索行为限制为以开头匹配。
ordering_fields 属性指定了一个字符串元组,其值表示客户端可以指定的字段名称,以对结果进行排序。如果客户端没有指定排序字段,则响应将使用与视图相关的模型中指示的默认排序字段。
将以下加粗的行添加到在 views.py 文件中声明的 GameList 类中。新行指定了用于过滤、搜索和排序功能的字段。示例代码文件包含在 restful_python_chapter_04_02 文件夹中:
class GameList(generics.ListCreateAPIView):
queryset = Game.objects.all()
serializer_class = GameSerializer
name = 'game-list'
permission_classes = (
permissions.IsAuthenticatedOrReadOnly,
IsOwnerOrReadOnly,
)
filter_fields = (
'name',
'game_category',
'release_date',
'played',
'owner',
)
search_fields = (
'^name',
)
ordering_fields = (
'name',
'release_date',
)
def perform_create(self, serializer):
serializer.save(owner=self.request.user)
在这种情况下,我们在 filter_fields 属性中指定了许多字段名称。我们在字符串元组中包含了 'game_category' 和 'owner',因此客户端将能够包含这两个字段中的任何一个的字段值。我们将利用其他相关模型选项,这将在以后允许我们通过字段过滤相关模型。这样,我们将了解可用的不同自定义选项。
ordering_fields 属性指定了字符串元组中的两个字段名称,因此客户端将能够通过 name 或 release_date 对结果进行排序。
将以下突出显示的行添加到views.py文件中声明的PlayerList类。新行指定了用于过滤、搜索和排序功能的字段。示例代码文件包含在restful_python_chapter_04_02文件夹中:
class PlayerList(generics.ListCreateAPIView):
queryset = Player.objects.all()
serializer_class = PlayerSerializer
name = 'player-list'
filter_fields = (
'name',
'gender',
)
search_fields = (
'^name',
)
ordering_fields = (
'name',
)
将以下行添加到views.py文件中,在PlayerScoreList类声明之前创建新的PlayerScoreFilter类。示例代码文件包含在restful_python_chapter_04_02文件夹中:
class PlayerScoreFilter(filters.FilterSet):
min_score = NumberFilter(
name='score', lookup_expr='gte')
max_score = NumberFilter(
name='score', lookup_expr='lte')
from_score_date = DateTimeFilter(
name='score_date', lookup_expr='gte')
to_score_date = DateTimeFilter(
name='score_date', lookup_expr='lte')
player_name = AllValuesFilter(
name='player__name')
game_name = AllValuesFilter(
name='game__name')
class Meta:
model = PlayerScore
fields = (
'score',
'from_score_date',
'to_score_date',
'min_score',
'max_score',
#player__name will be accessed as player_name
'player_name',
#game__name will be accessed as game_name
'game_name',
)
PlayerScoreFilter是rest_framework.filters.FilterSet类的子类。我们希望为在PlayerScoreList基于类的视图中用于过滤的字段自定义设置,因此创建了新的PlayerScoreFilter类。该类声明了以下六个类属性:
-
min_score:它是一个django_filters.NumberFilter实例,允许客户端过滤得分数值大于或等于指定数字的玩家得分。name的值表示应用于数字过滤器的字段,即'score',而lookup_expr的值表示查找表达式,即'gte',表示大于或等于。 -
max_score:它是一个django_filters.NumberFilter实例,允许客户端过滤得分数值小于或等于指定数字的玩家得分。name的值表示应用于数字过滤器的字段,即'score',而lookup_expr的值表示查找表达式,即'lte',表示小于或等于。 -
from_score_date:它是一个django_filters.DateTimeFilter实例,允许客户端过滤得分日期时间值大于或等于指定日期时间的玩家得分。name的值表示应用于日期时间过滤器的字段,即'score_date',而lookup_expr的值表示查找表达式,即'gte'。 -
to_score_date:它是一个django_filters.DateTimeFilter实例,允许客户端过滤得分日期时间值小于或等于指定日期时间的玩家得分。name的值表示应用于日期时间过滤器的字段,即'score_date',而lookup_expr的值表示查找表达式,即'lte'。 -
player_name:这是一个django_filters.AllValuesFilter:它是一个实例,允许客户端过滤与指定的字符串值匹配的玩家得分。name的值表示过滤器应用到的字段,'player__name'。请注意,该值有一个双下划线(__),你可以将其读作player模型的name字段,或者简单地用点替换双下划线并读取player.name。名称使用 Django 的双下划线语法。然而,我们不想让客户端使用player__name来指定玩家名称的过滤器。因此,该实例存储在名为player_name的类属性中,玩家和名称之间只有一个单下划线。可浏览的 API 将显示一个下拉菜单,其中包含所有可能的玩家名称值,用作过滤器。下拉菜单将仅包括已注册得分的玩家名称,因为我们使用了AllValuesFilter类。 -
game_name:这是一个django_filters.AllValuesFilter实例,允许客户端过滤与指定的字符串值匹配的游戏名称的玩家得分。name的值表示过滤器应用到的字段,'game__name'。名称使用之前解释过的 Django 的双下划线语法。与player_name的情况一样,我们不想让客户端使用game__name来指定游戏名称的过滤器,因此,我们将该实例存储在名为game_name的类属性中,游戏和名称之间只有一个单下划线。可浏览的 API 将显示一个下拉菜单,其中包含所有可能的游戏名称值,用作过滤器。下拉菜单将仅包括已注册得分的游戏名称,因为我们使用了AllValuesFilter类。
此外,PlayerScoreFilter 类声明了一个 Meta 内部类,该类声明了两个属性:model 和 fields。model 属性指定与过滤器集相关的模型,即 PlayerScore 类。fields 属性指定一个字符串元组,其值表示我们想在相关模型的过滤器中包含的字段名称和过滤器名称。我们包含了 'scores' 和之前声明的所有过滤器名称。字符串 'scores' 指的是 score 字段名称,我们希望应用默认的数值过滤器,该过滤器将在幕后构建,以便客户端可以通过 score 字段上的精确匹配进行过滤。
最后,将以下突出显示的行添加到在 views.py 文件中声明的 PlayerScoreList 类。示例的代码文件包含在 restful_python_chapter_04_02 文件夹中:
class PlayerScoreList(generics.ListCreateAPIView):
queryset = PlayerScore.objects.all()
serializer_class = PlayerScoreSerializer
name = 'playerscore-list'
filter_class = PlayerScoreFilter
ordering_fields = (
'score',
'score_date',
)
filter_class 属性指定了我们想用于此类视图的 FilterSet 子类:PlayerScoreFilter。此外,我们在 ordering_fields 字符串元组中指定了客户端将能够用于排序的两个字段名称。
测试过滤、搜索和排序
现在,我们可以启动 Django 的开发服务器来编写和发送 HTTP 请求。根据您的需求,执行以下两个命令之一以在其他连接到您局域网的设备或计算机上访问 API。请记住,我们在第一章中分析了它们之间的区别,使用 Django 开发 RESTful API。
python manage.py runserver
python manage.py runserver 0.0.0.0:8000
在我们运行任何之前的命令后,开发服务器将监听端口8000:
现在,我们将编写并发送一个 HTTP 请求来检索所有名称匹配3D RPG的游戏类别:
http :8000/game-categories/?name=3D+RPG
以下是对应的curl命令:
curl -iX GET :8000/game-categories/?name=3D+RPG
以下行显示了与过滤器中指定的名称匹配的单个游戏类别的示例响应。以下行仅显示 JSON 正文,不包含头部信息:
{
"count": 1,
"next": null,
"previous": null,
"results": [
{
"games": [
"http://localhost:8000/games/2/",
"http://localhost:8000/games/15/",
"http://localhost:8000/games/3/",
"http://localhost:8000/games/16/"
],
"name": "3D RPG",
"pk": 3,
"url": "http://localhost:8000/game-categories/3/"
}
]
}
我们将编写并发送一个 HTTP 请求来检索所有相关类别 ID 等于3且玩过的字段值等于True的游戏。我们希望按release_date降序排序结果,因此,我们在ordering的值中指定-release_date。字段名前的连字符(-)指定了使用降序而不是默认升序的排序功能。确保将3替换为之前检索到的名为3D RPG的游戏类别的 pk 值。玩过的字段是一个bool字段,因此,在指定过滤器中bool字段的期望值时,我们必须使用 Python 有效的bool值(True和False):
http ':8000/games/?game_category=3&played=True&ordering=-release_date'
以下是对应的curl命令:
curl -iX GET ':8000/games/?game_category=3&played=True&ordering=-release_date'
以下行显示了与过滤器中指定的标准匹配的两个游戏的示例响应。以下行仅显示 JSON 正文,不包含头部信息:
{
"count": 2,
"next": null,
"previous": null,
"results": [
{
"game_category": "3D RPG",
"name": "PvZ Garden Warfare 4",
"owner": "superuser",
"played": true,
"release_date": "2016-06-21T03:02:00.776594Z",
"url": "http://localhost:8000/games/2/"
},
{
"game_category": "3D RPG",
"name": "Superman vs Aquaman",
"owner": "superuser",
"played": true,
"release_date": "2016-06-21T03:02:00.776594Z",
"url": "http://localhost:8000/games/3/"
}
]
}
在GameList类中,我们将'game_category'指定为filter_fields元组中的字符串之一。因此,我们必须在过滤器中使用游戏类别 ID。现在,我们将使用与已注册分数相关的游戏名称的过滤器。PlayerScoreFilter类为我们提供了在game_name中相关游戏的过滤器。我们将该过滤器与另一个与已注册分数相关的玩家名称的过滤器结合起来。PlayerScoreFilter类为我们提供了在player_name中相关玩家的过滤器。必须满足标准中指定的两个条件,因此,过滤器使用AND运算符组合:
http ':8000/player-scores/?player_name=Kevin&game_name=Superman+vs+Aquaman'
以下是对应的curl命令:
curl -iX GET ':8000/player-scores/?player_name=Kevin&game_name=Superman+vs+Aquaman'
以下行显示了与过滤器中指定的标准匹配的分数的示例响应。以下行仅显示 JSON 正文,不包含头部信息:
{
"count": 1,
"next": null,
"previous": null,
"results": [
{
"game": "Superman vs Aquaman",
"pk": 5,
"player": "Kevin",
"score": 123200,
"score_date": "2016-06-22T03:02:00.776594Z",
"url": "http://localhost:8000/player-scores/5/"
}
]
}
我们将编写并发送一个 HTTP 请求来检索所有符合以下标准的分数。结果将按score_date降序排序。
-
score的值在 30,000 到 150,000 之间 -
score_date的值在 2016-06-21 和 2016-06-22 之间
http ':8000/player-scores/?score=&from_score_date=2016-06-01&to_score_date=2016-06-28&min_score=30000&max_score=150000&ordering=-score_date'
以下是对应的curl命令:
curl -iX GET ':8000/player-scores/?score=&from_score_date=2016-06-01&to_score_date=2016-06-28&min_score=30000&max_score=150000&ordering=-score_date'
以下几行显示了一个示例响应,其中包含符合筛选器中指定标准的三个游戏。我们用请求中指定的排序覆盖了模型中指定的默认排序。以下几行仅显示 JSON 主体,不包含头部信息:
{
"count": 3,
"next": null,
"previous": null,
"results": [
{
"game": "Superman vs Aquaman",
"pk": 5,
"player": "Kevin",
"score": 123200,
"score_date": "2016-06-22T03:02:00.776594Z",
"url": "http://localhost:8000/player-scores/5/"
},
{
"game": "PvZ Garden Warfare 4",
"pk": 4,
"player": "Brandon",
"score": 85125,
"score_date": "2016-06-22T01:02:00.776594Z",
"url": "http://localhost:8000/player-scores/4/"
},
{
"game": "PvZ Garden Warfare 4",
"pk": 3,
"player": "Brandon",
"score": 35000,
"score_date": "2016-06-21T03:02:00.776594Z",
"url": "http://localhost:8000/player-scores/3/"
}
]
}
小贴士
在前面的请求中,所有响应的页面不超过一页。如果响应需要超过一页,previous 和 next 键的值将显示包含筛选、搜索、排序和分页组合的 URL。
我们将组合并发送一个 HTTP 请求来检索所有以 'S' 开头的游戏。我们将使用我们配置的搜索功能,将搜索行为限制在 name 字段的以开始匹配上:
http ':8000/games/?search=S'
以下是对应的 curl 命令:
curl -iX GET ':8000/games/?search=S'
以下几行显示了一个示例响应,其中包含符合指定搜索标准的两个游戏。以下几行仅显示 JSON 主体,不包含头部信息:
{
"count": 2,
"next": null,
"previous": null,
"results": [
{
"game_category": "2D mobile arcade",
"name": "Scribblenauts Unlimited",
"owner": "superuser",
"played": false,
"release_date": "2016-06-21T03:02:00.776594Z",
"url": "http://localhost:8000/games/7/"
},
{
"game_category": "3D RPG",
"name": "Superman vs Aquaman",
"owner": "superuser",
"played": true,
"release_date": "2016-06-21T03:02:00.776594Z",
"url": "http://localhost:8000/games/3/"
}
]
}
小贴士
我们可以更改搜索和排序参数的默认名称:'search' 和 'ordering'。我们只需在 SEARCH_PARAM 和 ORDERING_PARAM 设置中指定所需的名称。
可浏览 API 中的筛选、搜索和排序
我们可以利用可浏览的 API,通过网页浏览器轻松测试筛选、搜索和排序功能。打开网页浏览器,输入 http://localhost:8000/player-scores/。如果你使用另一台计算机或设备运行浏览器,请将 localhost 替换为运行 Django 开发服务器的计算机的 IP 地址。可浏览的 API 将组合并发送一个 GET 请求到 /player-scores/,并将显示其执行结果,即头部信息和 JSON 玩家得分列表。你会注意到在 OPTIONS 按钮的左侧有一个新的 筛选器 按钮。
点击 筛选器,可浏览的 API 将显示 筛选器 对话框,其中包含适用于每个筛选器的适当控件,这些控件位于 字段筛选器 下方,以及位于 排序 下的不同排序选项。以下截图显示了 筛选器 对话框:

玩家姓名 和 游戏名称 下拉菜单将只包括已注册得分的相关玩家和游戏名称,因为我们为两个筛选器都使用了 AllValuesFilter 类。在输入所有筛选器的值后,我们可以选择所需的排序选项或点击 提交。可浏览的 API 将组合并发送适当的 HTTP 请求,并渲染一个显示其执行结果的网页。结果将包括发送到 Django 服务器的 HTTP 请求。以下截图显示了执行下一个请求的示例结果,即我们使用可浏览的 API 构建的请求:
GET /player-scores/?score=&from_score_date=&to_score_date=&min_score=30000&max_score=40000&player_name=Brandon&game_name=PvZ+Garden+Warfare+4

设置单元测试
首先,我们将安装 coverage 和 django-nose 包到我们的虚拟环境中。我们将进行必要的配置以使用 django_nose.NoseTestRunner 类来运行所有编写的测试,并且我们将使用必要的配置来提高测试覆盖率测量的准确性。
确保您退出 Django 的开发服务器。请记住,您只需在终端或正在运行的命令提示符窗口中按 Ctrl + C 即可。我们只需运行以下命令来安装 coverage 包:
pip install coverage
输出的最后几行表明 django-nose 包已成功安装:
Collecting coverage
Downloading coverage-4.1.tar.gz
Installing collected packages: coverage
Running setup.py install for coverage
Successfully installed coverage-4.1
我们只需运行以下命令来安装 django-nose 包:
pip install django-nose
输出的最后几行表明 django-nose 包已成功安装。
Collecting django-nose
Downloading django_nose-1.4.4-py2.py3-none-any.whl
Collecting nose>=1.2.1 (from django-nose)
Downloading nose-1.3.7-py3-none-any.whl
Installing collected packages: nose, django-nose
Successfully installed django-nose-1.4.4 nose-1.3.7
将 'django_nose' 添加到 settings.py 文件中已安装的应用程序,具体来说,添加到 INSTALLED_APPS 字符串列表中。以下代码显示了我们需要添加的突出显示的代码行。示例代码文件包含在 restful_python_chapter_04_03 文件夹中:
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
# Django REST Framework
'rest_framework',
# Games application
'games.apps.GamesConfig',
# Crispy forms
'crispy_forms',
# Django nose
'django_nose',
]
打开 gamesapi/settings.py 文件,并添加以下行以配置 django_nose.NoseTestRunner 类作为我们的测试运行器,并指定我们在运行测试时将使用的默认命令行选项。示例代码文件包含在 restful_python_chapter_04_03 文件夹中:
# We want to use nose to run all the tests
TEST_RUNNER = 'django_nose.NoseTestSuiteRunner'
# We want nose to measure coverage on the games app
NOSE_ARGS = [
'--with-coverage',
'--cover-erase',
'--cover-inclusive',
'--cover-package=games',
]
NOSE_ARGS 设置指定了 nose 测试套件运行器和覆盖率所使用的以下命令行选项:
-
--with-coverage: 这个选项指定我们始终想要生成测试覆盖率报告。 -
--cover-erase: 这个选项确保测试运行器从之前的运行中删除覆盖率测试结果。 -
--cover-inclusive: 这个选项将工作目录下的所有 Python 文件包含在覆盖率报告中。这样,我们确保在测试套件中没有导入所有文件时,能够发现测试覆盖的漏洞。我们将创建一个不会导入所有文件的测试套件,因此,这个选项对于获得准确的测试覆盖率报告非常重要。 -
--cover-package=games: 这个选项表示我们想要覆盖的模块:games。
最后,在 gamesapi 根目录下创建一个名为 .coveragerc 的新文本文件,内容如下:
[run]
omit = *migrations*
这样,coverage 工具在提供测试覆盖率报告时不会考虑与生成的迁移相关的许多事情。我们将使用这个设置文件获得更准确的测试覆盖率报告。
编写第一轮单元测试
现在,我们将编写第一轮单元测试。具体来说,我们将编写与基于类的游戏类别视图相关的单元测试:GameCategoryList和GameCategoryDetail。打开现有的games/test.py文件,并用以下行替换现有代码,这些行声明了许多import语句和GameCategoryTests类。示例代码文件包含在restful_python_chapter_04_04文件夹中,如下所示:
from django.test import TestCase
from django.core.urlresolvers import reverse
from django.utils.http import urlencode
from rest_framework import status
from rest_framework.test import APITestCase
from games.models import GameCategory
class GameCategoryTests(APITestCase):
def create_game_category(self, name):
url = reverse('gamecategory-list')
data = {'name': name}
response = self.client.post(url, data, format='json')
return response
def test_create_and_retrieve_game_category(self):
"""
Ensure we can create a new GameCategory and then retrieve it
"""
new_game_category_name = 'New Game Category'
response = self.create_game_category(new_game_category_name)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(GameCategory.objects.count(), 1)
self.assertEqual(
GameCategory.objects.get().name,
new_game_category_name)
print("PK {0}".format(GameCategory.objects.get().pk))
GameCategoryTests类是rest_framework.test.APITestCase的子类。该类声明了create_game_category方法,该方法将新游戏类别的所需name作为参数。该方法构建 URL 和数据字典,以向与gamecategory-list视图名称关联的视图发送 HTTP POST方法,并返回此请求生成的响应。代码使用self.client来访问允许我们轻松组合和发送 HTTP 请求进行测试的APIClient实例。在这种情况下,代码使用构建的url、data字典和所需的数据格式'json'调用post方法。许多测试方法将调用create_game_category方法来创建游戏类别,然后向 API 发送其他 HTTP 请求。
test_create_and_retrieve_game_category方法测试我们是否可以创建一个新的GameCategory并检索它。该方法调用前面解释的create_game_category方法,然后使用assertEqual检查以下预期结果:
-
响应的
status_code是 HTTP 201 Created(status.HTTP_201_CREATED) -
从数据库检索到的
GameCategory对象总数为1
将以下方法添加到我们在games/test.py文件中创建的GameCategoryTests类中。示例代码文件包含在restful_python_chapter_04_04文件夹中:
def test_create_duplicated_game_category(self):
"""
Ensure we can create a new GameCategory.
"""
url = reverse('gamecategory-list')
new_game_category_name = 'New Game Category'
data = {'name': new_game_category_name}
response1 = self.create_game_category(new_game_category_name)
self.assertEqual(
response1.status_code,
status.HTTP_201_CREATED)
response2 = self.create_game_category(new_game_category_name)
self.assertEqual(
response2.status_code,
status.HTTP_400_BAD_REQUEST)
def test_retrieve_game_categories_list(self):
"""
Ensure we can retrieve a game cagory
"""
new_game_category_name = 'New Game Category'
self.create_game_category(new_game_category_name)
url = reverse('gamecategory-list')
response = self.client.get(url, format='json')
self.assertEqual(
response.status_code,
status.HTTP_200_OK)
self.assertEqual(
response.data['count'],
1)
self.assertEqual(
response.data['results'][0]['name'],
new_game_category_name)
def test_update_game_category(self):
"""
Ensure we can update a single field for a game category
"""
new_game_category_name = 'Initial Name'
response = self.create_game_category(new_game_category_name)
url = reverse(
'gamecategory-detail',
None,
{response.data['pk']})
updated_game_category_name = 'Updated Game Category Name'
data = {'name': updated_game_category_name}
patch_response = self.client.patch(url, data, format='json')
self.assertEqual(
patch_response.status_code,
status.HTTP_200_OK)
self.assertEqual(
patch_response.data['name'],
updated_game_category_name)
def test_filter_game_category_by_name(self):
"""
Ensure we can filter a game category by name
"""
game_category_name1 = 'First game category name'
self.create_game_category(game_category_name1)
game_caregory_name2 = 'Second game category name'
self.create_game_category(game_caregory_name2)
filter_by_name = { 'name' : game_category_name1 }
url = '{0}?{1}'.format(
reverse('gamecategory-list'),
urlencode(filter_by_name))
response = self.client.get(url, format='json')
self.assertEqual(
response.status_code,
status.HTTP_200_OK)
self.assertEqual(
response.data['count'],
1)
self.assertEqual(
response.data['results'][0]['name'],
game_category_name1)
我们添加了以下以test_前缀开头的方法:
-
test_create_duplicated_game_category: 测试唯一约束是否使我们无法创建两个具有相同名称的游戏类别。当我们第二次使用重复的类别名称组合并发送 HTTP POST 请求时,我们必须收到一个HTTP 400 Bad Request状态码(status.HTTP_400_BAD_REQUEST) -
test_retrieve_game_categories_list: 测试我们是否可以通过主键或 id 检索特定的游戏类别 -
test_update_game_category: 测试我们是否可以更新游戏类别的单个字段 -
test_filter_game_category_by_name: 测试我们是否可以通过名称过滤游戏类别
提示
注意,每个需要在数据库中具有特定条件的测试都必须执行使数据库处于该特定条件的所有必要代码。例如,为了更新现有的游戏类别,我们首先必须创建一个新的游戏类别,然后我们才能更新它。每个测试方法将在数据库中不包含先前执行的测试方法的数据的情况下执行,也就是说,每个测试都将使用从先前测试中清除数据的数据库运行。
上一列表中的最后三种方法通过检查响应 JSON 体的 data 属性来验证包含在响应中的数据。例如,第一行检查 count 的值是否等于 1,接下来的行检查 results 数组中第一个元素的 name 键是否等于 new_game_category_name 变量中持有的值:
self.assertEqual(response.data['count'], 1)
self.assertEqual(
response.data['results'][0]['name'],
new_game_category_name)
test_filter_game_category_by_name 方法调用 django.utils.http.urlencode 函数,从指定字段名称和我们要用于过滤检索数据的值的 filter_by_name 字典生成一个编码的 URL。以下行显示了生成 URL 并将其保存到 url 变量的代码。如果 game_cagory_name1 是 'First game category name',则 urlencode 函数调用的结果将是 'name=First+game+category+name'。
filter_by_name = { 'name' : game_category_name1 }
url = '{0}?{1}'.format(
reverse('gamecategory-list'),
urlencode(filter_by_name))
运行单元测试并检查测试覆盖率
现在,运行以下命令以创建测试数据库,运行所有迁移,并使用 Django nose 测试运行器执行我们创建的所有测试。测试运行器将执行以 test_ 前缀开始的我们 GameCategoryTests 类的所有方法,并将显示结果。
小贴士
在处理 API 时,测试不会更改我们一直在使用的数据库。
记住我们配置了许多默认的命令行选项,它们将在不输入它们的情况下使用。在我们在其中使用的同一虚拟环境中运行以下命令。我们将使用 -v 2 选项来使用 2 级详细程度,因为我们想检查测试运行器正在执行的所有事情:
python manage.py test -v 2
以下行显示了示例输出:
nosetests --with-coverage --cover-package=games --cover-erase --cover-inclusive -v --verbosity=2
Creating test database for alias 'default' ('test_games')...
Operations to perform:
Synchronize unmigrated apps: django_nose, staticfiles, crispy_forms, messages, rest_framework
Apply all migrations: games, admin, auth, contenttypes, sessions
Synchronizing apps without migrations:
Creating tables...
Running deferred SQL...
Running migrations:
Rendering model states... DONE
Applying contenttypes.0001_initial... OK
Applying auth.0001_initial... OK
Applying admin.0001_initial... OK
Applying admin.0002_logentry_remove_auto_add... OK
Applying contenttypes.0002_remove_content_type_name... OK
Applying auth.0002_alter_permission_name_max_length... OK
Applying auth.0003_alter_user_email_max_length... OK
Applying auth.0004_alter_user_username_opts... OK
Applying auth.0005_alter_user_last_login_null... OK
Applying auth.0006_require_contenttypes_0002... OK
Applying auth.0007_alter_validators_add_error_messages... OK
Applying games.0001_initial... OK
Applying games.0002_auto_20160623_2131... OK
Applying games.0003_game_owner... OK
Applying sessions.0001_initial... OK
Ensure we can create a new GameCategory and then retrieve it ... ok
Ensure we can create a new GameCategory. ... ok
Ensure we can filter a game category by name ... ok
Ensure we can retrieve a game cagory ... ok
Ensure we can update a single field for a game category ... ok
Name Stmts Miss Cover
------------------------------------------
games.py 0 0 100%
games/admin.py 1 1 0%
games/apps.py 3 3 0%
games/models.py 36 35 3%
games/pagination.py 3 0 100%
games/permissions.py 6 3 50%
games/serializers.py 45 0 100%
games/urls.py 3 0 100%
games/views.py 91 2 98%
------------------------------------------
TOTAL 188 44 77%
------------------------------------------
Ran 5 tests in 0.143s
OK
Destroying test database for alias 'default' ('test_games')...
输出提供了详细信息,表明测试运行器执行了 5 个测试,并且所有测试都通过了。在执行迁移的详细信息之后,输出显示了我们在 GameCategoryTests 类中为每个方法包含的注释,这些注释以 test_ 前缀开头,代表要执行的测试。以下列表显示了注释中包含的描述以及它们所代表的方法:
-
确保我们可以创建一个新的 GameCategory 并然后检索它:
test_create_and_retrieve_game_category。 -
确保我们可以创建一个新的 GameCategory:
test_create_duplicated_game_category。 -
确保我们可以通过名称过滤游戏类别:
test_retrieve_game_categories_list。 -
确保我们可以检索一个游戏类别:
test_update_game_category。 -
确保我们可以更新游戏类别的单个字段:
test_filter_game_category_by_name。
coverage 包提供的测试代码覆盖率测量报告使用 Python 标准库中包含的代码分析工具和跟踪钩子来确定哪些代码行是可执行的,以及哪些行已被执行。报告提供了一个包含以下列的表格:
-
名称:Python 模块名称。 -
Stmts:Python 模块的语句计数。 -
Miss:未执行的语句数量,即未执行的语句。 -
覆盖率:可执行语句的覆盖率,以百分比表示。
根据报告中显示的测量结果,models.py 的覆盖率肯定非常低。实际上,我们只编写了一些与 GameCategory 模型相关的测试,因此,覆盖率对于模型来说确实很低:
我们可以使用带有 -m 命令行选项的 coverage 命令来显示缺失语句的行号,并在新的 Missing 列中显示。
coverage report -m
命令将使用上次执行的信息,并显示缺失的语句。以下几行显示了与之前单元测试执行相对应的示例输出:
Name Stmts Miss Cover Missing
----------------------------------------------------
games/__init__.py 0 0 100%
games/admin.py 1 1 0% 1
games/apps.py 3 3 0% 1-5
games/models.py 36 35 3% 1-10, 14-70
games/pagination.py 3 0 100%
games/permissions.py 6 3 50% 6-9
games/serializers.py 45 0 100%
games/tests.py 55 0 100%
games/urls.py 3 0 100%
games/views.py 91 2 98% 83, 177
----------------------------------------------------
TOTAL 243 44 82%
现在,运行以下命令以获取详细说明缺失行的注释 HTML 列表:
coverage html
使用您的网络浏览器打开在 htmlcov 文件夹中生成的 index.html HTML 文件。以下图片显示了以 HTML 格式生成的覆盖率报告示例。

点击或轻触 games/models.py,网页浏览器将渲染一个显示已运行、缺失和排除的语句的网页,不同颜色区分。我们可以点击或轻触 运行、缺失 和 排除 按钮,以显示或隐藏代表每行代码状态的背景色。默认情况下,缺失的代码行将以粉色背景显示。因此,我们必须编写针对这些代码行的单元测试来提高我们的测试覆盖率:

提高测试覆盖率
现在,我们将编写额外的单元测试来提高测试覆盖率。具体来说,我们将编写与玩家类视图相关的单元测试:PlayerList 和 PlayerDetail。打开现有的 games/test.py 文件,在声明导入的最后一行之后插入以下行。我们需要一个新的 import 语句,并将声明新的 PlayerTests 类。示例代码文件包含在 restful_python_chapter_04_05 文件夹中:
from games.models import Player
class PlayerTests(APITestCase):
def create_player(self, name, gender):
url = reverse('player-list')
data = {'name': name, 'gender': gender}
response = self.client.post(url, data, format='json')
return response
def test_create_and_retrieve_player(self):
"""
Ensure we can create a new Player and then retrieve it
"""
new_player_name = 'New Player'
new_player_gender = Player.MALE
response = self.create_player(new_player_name, new_player_gender)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(Player.objects.count(), 1)
self.assertEqual(
Player.objects.get().name,
new_player_name)
def test_create_duplicated_player(self):
"""
Ensure we can create a new Player and we cannot create a duplicate.
"""
url = reverse('player-list')
new_player_name = 'New Female Player'
new_player_gender = Player.FEMALE
response1 = self.create_player(new_player_name, new_player_gender)
self.assertEqual(
response1.status_code,
status.HTTP_201_CREATED)
response2 = self.create_player(new_player_name, new_player_gender)
self.assertEqual(
response2.status_code,
status.HTTP_400_BAD_REQUEST)
def test_retrieve_players_list(self):
"""
Ensure we can retrieve a player
"""
new_player_name = 'New Female Player'
new_player_gender = Player.FEMALE
self.create_player(new_player_name, new_player_gender)
url = reverse('player-list')
response = self.client.get(url, format='json')
self.assertEqual(
response.status_code,
status.HTTP_200_OK)
self.assertEqual(
response.data['count'],
1)
self.assertEqual(
response.data['results'][0]['name'],
new_player_name)
self.assertEqual(
response.data['results'][0]['gender'],
new_player_gender)
PlayerTests类是rest_framework.test.APITestCase的子类。该类声明了create_player方法,该方法接收新玩家所需的name和gender作为参数。该方法构建 URL 和数据字典,以组成并发送一个 HTTP POST方法到与player-list视图名称关联的视图,并返回此请求生成的响应。许多测试方法将调用create_player方法来创建玩家,然后组成并发送其他 HTTP 请求到 API。
该类声明了以下以test_前缀开始的方法:
-
test_create_and_retrieve_player:测试我们是否可以创建一个新的Player并检索它。 -
test_create_duplicated_player:测试唯一约束是否使我们无法创建具有相同名称的两个玩家。当我们第二次使用重复的玩家名称组成并发送 HTTP POST 请求时,我们必须收到 HTTP 400 Bad Request 状态码(status.HTTP_400_BAD_REQUEST)。 -
test_retrieve_player_list:测试我们是否可以通过其主键或 id 检索特定的游戏类别。
我们只是编写了一些与玩家相关的测试来提高测试覆盖率,并注意到对测试覆盖率报告的影响。
现在,在同一个虚拟环境中运行以下命令。我们将使用-v 2选项来使用 2 级详细程度,因为我们想检查测试运行器所做的一切:
python manage.py test -v 2
以下行显示了样本输出的最后几行:
Ensure we can create a new GameCategory and then retrieve it ... ok
Ensure we can create a new GameCategory. ... ok
Ensure we can filter a game category by name ... ok
Ensure we can retrieve a game cagory ... ok
Ensure we can update a single field for a game category ... ok
Ensure we can create a new Player and then retrieve it ... ok
Ensure we can create a new Player and we cannot create a duplicate. ... ok
Ensure we can retrieve a player ... ok
Name Stmts Miss Cover
------------------------------------------
games.py 0 0 100%
games/admin.py 1 1 0%
games/apps.py 3 3 0%
games/models.py 36 34 6%
games/pagination.py 3 0 100%
games/permissions.py 6 3 50%
games/serializers.py 45 0 100%
games/urls.py 3 0 100%
games/views.py 91 2 98%
------------------------------------------
TOTAL 188 43 77%
----------------------------------------------------------------------
Ran 8 tests in 0.168s
OK
Destroying test database for alias 'default' ('test_games')...
输出提供了细节,表明测试运行器执行了 8 个测试,并且所有测试都通过了。由coverage包提供的测试代码覆盖率测量报告将前一次运行的Cover百分比从 3%提高到 6%。我们编写的附加测试执行了Player模型的代码,因此,覆盖率报告中有所影响。
小贴士
我们只是创建了一些单元测试来了解我们如何编写它们。然而,当然,编写更多的测试以提供 API 中包含的所有功能和执行场景的适当覆盖率是必要的。
理解部署和可扩展性的策略
与 Django 和 Django REST Framework 相关的一个最大的缺点是每个 HTTP 请求都是阻塞的。因此,每当 Django 服务器收到一个 HTTP 请求时,它不会开始处理队列中的任何其他 HTTP 请求,直到服务器为它收到的第一个 HTTP 请求发送响应。
然而,RESTful Web 服务的最大优点之一是它们是无状态的,也就是说,它们不应该在任何服务器上保持客户端状态。我们的 API 是一个无状态 RESTful Web 服务的良好例子。因此,我们可以让 API 在尽可能多的服务器上运行,以实现我们的可扩展性目标。显然,我们必须考虑到我们很容易将数据库服务器转化为我们的可扩展性瓶颈。
小贴士
现在,我们有大量的基于云的替代方案来部署使用 Django 和 Django REST Framework 的 RESTful Web 服务,使其具有极高的可扩展性。仅举几个例子,我们有 Heroku、PythonAnywhere、Google App Engine、OpenShift、AWS Elastic Beanstalk 和 Windows Azure。
每个平台都包含了部署我们应用程序的详细说明。所有这些都需要我们生成requirements.txt文件,该文件列出了应用程序及其版本依赖项。这样,平台就能够安装文件中列出的所有必要依赖项。
运行以下pip freeze命令,以生成requirements.txt文件:
pip freeze > requirements.txt
以下行显示了示例生成的requirements.txt文件的内容。然而,请注意,许多包的版本号增长很快,你可能会在你的配置中看到不同的版本:
coverage==4.1
Django==1.9.7
django-braces==1.9.0
django-crispy-forms==1.6.0
django-filter==0.13.0
django-nose==1.4.4
django-oauth-toolkit==0.10.0
djangorestframework==3.3.3
nose==1.3.7
oauthlib==1.0.3
psycopg2==2.6.2
six==1.10.0
在部署我们的第一个版本的 RESTful Web 服务之前,我们始终要确保对 API 和数据库进行性能分析。确保生成的查询在底层数据库上运行正常,以及最常用的查询不会最终变成顺序扫描,这一点非常重要。通常情况下,需要在数据库中的表上添加适当的索引。
我们一直在使用基本的 HTTP 身份验证。如果我们决定使用这种身份验证或其他机制,我们必须确保 API 在生产环境中运行在 HTTPS 下。此外,我们必须确保我们更改settings.py文件中的以下行:
DEBUG = True
我们必须始终在生产环境中关闭调试模式,因此,我们必须将上一行替换为以下一行:
DEBUG = False
测试你的知识
-
ScopedRateThrottle类:-
限制特定用户可以发起的请求数量。
-
限制使用
throttle_scope属性值标识的 API 特定部分的请求数量。 -
限制匿名用户可以发起的请求数量。
-
-
UserRateThrottle类:-
限制特定用户可以发起的请求数量。
-
限制使用
throttle_scope属性值标识的 API 特定部分的请求数量。 -
限制匿名用户可以发起的请求数量。
-
-
DjangoFilterBackend类:-
提供基于单个查询参数的搜索功能,并且基于 Django 管理员的搜索功能。
-
允许客户端通过单个查询参数控制结果的排序方式。
-
提供字段过滤功能。
-
-
SearchFilter类:-
提供基于单个查询参数的搜索功能,并且基于 Django 管理员的搜索功能。
-
允许客户端通过单个查询参数控制结果的排序方式。
-
提供字段过滤功能。
-
-
在
APITestCase的子类中,self.client是:-
APIClient实例允许我们轻松地组合和发送 HTTP 请求进行测试。 -
允许我们轻松组合和发送 HTTP 请求进行测试的
APITestClient实例。 -
允许我们轻松组合和发送 HTTP 请求进行测试的
APITestCase实例。
-
摘要
在本章中,我们利用 Django REST Framework 包含的功能来定义限流策略。我们使用了过滤、搜索和排序类,使得配置过滤器、搜索查询和结果排序变得容易。我们使用了可浏览的 API 功能来测试我们 API 中包含的新特性。
我们编写了第一轮单元测试,测量了测试覆盖率,然后编写了额外的单元测试以提高测试覆盖率。最后,我们了解了关于部署和可扩展性的许多考虑因素。
现在我们已经使用 Django REST Framework 构建了一个复杂的 API 并对其进行了测试,接下来我们将转向另一个流行的 Python 网络框架 Flask,这是我们将在下一章中讨论的内容。
第五章. 使用 Flask 开发 RESTful API
在本章中,我们将开始使用 Flask 及其 Flask-RESTful 扩展;我们还将创建一个执行简单列表的 CRUD 操作的 RESTful Web API。我们将:
-
设计一个使用 Flask 和 Flask-RESTful 扩展执行 CRUD 操作的 RESTful API
-
理解每个 HTTP 方法执行的任务
-
使用 Flask 及其 Flask-RESTful 扩展设置虚拟环境
-
声明响应的状态码
-
创建表示资源的模型
-
使用字典作为存储库
-
配置序列化响应的输出字段
-
在 Flask 的可插拔视图之上进行资源路由
-
配置资源路由和端点
-
向 Flask API 发送 HTTP 请求
-
使用命令行工具与 Flask API 交互
-
使用 GUI 工具与 Flask API 交互
设计一个与简单数据源交互的 RESTful API
假设我们必须配置要显示在连接到物联网(Internet of Things)设备的 OLED 显示屏上的消息,该物联网设备能够运行 Python 3.5、Flask 和其他 Python 包。有一个团队正在编写代码,从字典中检索字符串消息并在连接到物联网设备的 OLED 显示屏上显示它们。我们必须开始开发一个移动应用和网站,该应用和网站需要与 RESTful API 交互以执行字符串消息的 CRUD 操作。
我们不需要 ORM,因为我们不会将字符串消息持久化到数据库中。我们只需使用内存中的字典作为我们的数据源。这是此 RESTful API 的要求之一。在这种情况下,RESTful 网络服务将在物联网设备上运行,即我们将在物联网设备上运行 Flask 开发服务器。
小贴士
我们肯定会失去 RESTful API 的可扩展性,因为我们服务器中有内存数据源,因此,我们无法在另一个物联网设备上运行 RESTful API。然而,我们将与另一个示例合作,该示例涉及更复杂的数据源,该数据源将能够以后以 RESTful 方式扩展。第一个示例将使我们了解 Flask 和 Flask-RESTful 如何与一个非常简单的内存数据源一起工作。
我们选择 Flask,因为它比 Django 更轻量级,我们不需要配置 ORM,我们希望尽快在物联网设备上运行 RESTful API,以便所有团队都能与之交互。我们也将使用 Flask 编写网站,因此,我们希望使用相同的 Web 微框架来驱动网站和 RESTful 网络服务。
Flask 有许多可用的扩展,使得使用 Flask 微框架执行特定任务变得更容易。我们将利用 Flask-RESTful 扩展,这将允许我们在构建 RESTful API 时鼓励最佳实践。在这种情况下,我们将使用 Python 字典作为数据源。如前所述,在未来的示例中,我们将使用更复杂的数据源。
首先,我们必须指定我们主要资源(消息)的要求。对于消息,我们需要以下属性或字段:
-
一个整数标识符
-
一个字符串消息
-
一个表示消息在 OLED 显示屏上打印时间的秒数
-
创建日期和时间——当向集合添加新消息时,时间戳将自动添加
-
一个消息类别描述,例如“警告”和“信息”
-
一个表示消息在 OLED 显示屏上打印次数的整数计数器
-
一个表示消息是否至少在 OLED 显示屏上打印过一次的布尔值
以下表格显示了我们的 API 第一版必须支持的 HTTP 动词、作用域和语义。每个方法由一个 HTTP 动词和一个作用域组成,并且所有方法对所有消息和集合都有一个明确的含义。在我们的 API 中,每个消息都有自己的唯一 URL。
| HTTP 动词 | 作用域 | 语义 |
|---|---|---|
GET |
消息集合 | 获取集合中存储的所有消息,按名称升序排序 |
GET |
消息 | 获取单个消息 |
POST |
消息集合 | 在集合中创建一个新的消息 |
PATCH |
消息 | 更新现有消息的字段 |
DELETE |
消息 | 删除现有的消息 |
理解每个 HTTP 方法执行的任务
让我们考虑http://localhost:5000/api/messages/是消息集合的 URL。如果我们向前面的 URL 添加一个数字,我们就能识别一个特定的消息,其 id 等于指定的数值。例如,http://localhost:5000/api/messsages/6标识了 id 等于6的消息。
小贴士
我们希望我们的 API 能够在 URL 中区分集合和集合的单个资源。当我们引用集合时,我们将使用斜杠(/)作为 URL 的最后一个字符,如http://localhost:5000/api/messages/。当我们引用集合的单个资源时,我们不会在 URL 的最后一个字符使用斜杠(/),如http://localhost:5000/api/messages/6。
我们必须使用POST HTTP 动词和请求 URL http://localhost:5000/api/messages/来编写并发送一个 HTTP 请求以创建一条新消息。此外,我们必须提供包含字段名称和值的 JSON 键值对以创建新消息。作为请求的结果,服务器将验证提供的字段值,确保它是一个有效的消息,并将其持久化到消息字典中。
服务器将返回一个201 Created状态码和一个包含最近添加的消息序列化为 JSON 的 JSON 体,包括服务器自动生成并分配给消息对象的 ID:
POST http://localhost:5000/api/messages/
我们必须使用GET HTTP 动词和请求 URL http://localhost:5000/api/messages/{id}来编写并发送一个 HTTP 请求,以检索与在 {id} 处写入的指定数值匹配的消息。例如,如果我们使用请求 URL http://localhost:5000/api/messages/82,服务器将检索 ID 匹配 82 的游戏。作为请求的结果,服务器将从字典中检索具有指定 ID 的消息。
如果找到消息,服务器将序列化消息对象为 JSON,并返回一个200 OK状态码和一个包含序列化消息对象的 JSON 体。如果没有找到与指定 ID 或主键匹配的消息,服务器将返回一个404 Not Found状态:
GET http://localhost:5000/api/messages/{id}
我们必须使用PATCH HTTP 动词和请求 URL http://localhost:5000/api/messages/{id}来编写并发送一个 HTTP 请求以更新与在 {id} 处写入的指定数值匹配的消息的一个或多个字段。此外,我们必须提供包含要更新的字段名称及其新值的 JSON 键值对。作为请求的结果,服务器将验证提供的字段值,更新与指定 ID 匹配的消息上的这些字段,并在字典中更新消息,如果它是有效的消息。
服务器将返回一个200 OK状态码和一个包含最近更新的游戏序列化为 JSON 的 JSON 体。如果我们为要更新的字段提供无效数据,服务器将返回一个400 Bad Request状态码。如果服务器找不到具有指定 ID 的消息,服务器将仅返回一个404 Not Found状态:
PATCH http://localhost:5000/api/messages/{id}
小贴士
PATCH 方法将使我们能够轻松地更新消息的两个字段:表示消息被打印次数的整数计数器,以及指定消息是否至少被打印一次的布尔值。
我们必须使用DELETE HTTP 动词和http://localhost:5000/api/messages/{id}请求 URL 来组合和发送一个 HTTP 请求,以删除与在{id}位置指定的数值匹配的消息。例如,如果我们使用请求 URL http://localhost:5000/api/messages/15,服务器将删除id匹配15的消息。请求的结果是,服务器将从字典中检索具有指定id的消息。如果找到消息,服务器将请求字典删除与该消息对象关联的条目,并返回204 No Content状态码。如果没有找到与指定id匹配的消息,服务器将返回404 Not Found:
DELETE http://localhost:5000/api/messages/{id}
使用 Flask 和 Flask-RESTful 设置虚拟环境
在第一章 "使用 Django 开发 RESTful API"中,我们了解到,在本书中,我们将使用 Python 3.4 中引入并改进的轻量级虚拟环境。现在,我们将按照步骤创建一个新的轻量级虚拟环境,以便使用 Flask 和 Flask-RESTful。如果你没有 Python 中轻量级虚拟环境的经验,强烈建议阅读第一章,使用 Django 开发 RESTful API。该章节包含了我们将要遵循的步骤的所有详细解释。
首先,我们必须选择我们的虚拟环境的目标文件夹或目录。在示例中,我们将使用以下路径。虚拟环境的目标文件夹将是我们主目录中的PythonREST/Flask01文件夹。例如,如果我们的 macOS 或 Linux 中的主目录是/Users/gaston,虚拟环境将在/Users/gaston/PythonREST/Flask01中创建。您可以在每个命令中将指定的路径替换为您想要的路径,如下所示:
~/PythonREST/Flask01
在示例中,我们将使用以下路径。虚拟环境的目标文件夹将是我们用户配置文件中的PythonREST\Flask01文件夹。例如,如果我们的用户配置文件是C:\Users\Gaston,虚拟环境将在C:\Users\gaston\PythonREST\Flask01中创建。您可以在每个命令中将指定的路径替换为您想要的路径,如下所示:
%USERPROFILE%\PythonREST\Flask01
在 macOS 或 Linux 中打开终端并执行以下命令以创建虚拟环境:
python3 -m venv ~/PythonREST/Flask01
在 Windows 中,执行以下命令以创建虚拟环境:
python -m venv %USERPROFILE%\PythonREST\Flask01
上述命令不会产生任何输出。现在我们已经创建了虚拟环境,我们将运行一个特定平台的脚本以激活它。激活虚拟环境后,我们将安装只在此虚拟环境中可用的包。
如果您的终端配置为在 macOS 或 Linux 中使用 bash shell,请运行以下命令来激活虚拟环境。该命令也适用于 zsh shell:
source ~/PythonREST/Flask01/bin/activate
如果您的终端配置为使用 csh 或 tcsh shell,请运行以下命令来激活虚拟环境:
source ~/PythonREST/Flask01/bin/activate.csh
如果您的终端配置为使用 fish shell,请运行以下命令来激活虚拟环境:
source ~/PythonREST/Flask01/bin/activate.fish
在 Windows 中,您可以在命令提示符中运行批处理文件或 Windows PowerShell 脚本来激活虚拟环境。如果您更喜欢命令提示符,请在 Windows 命令行中运行以下命令来激活虚拟环境:
%USERPROFILE%\PythonREST\Flask01\Scripts\activate.bat
如果您更喜欢 Windows PowerShell,启动它并运行以下命令来激活虚拟环境。但是请注意,您应该在 Windows PowerShell 中启用脚本执行才能运行脚本:
cd $env:USERPROFILE
PythonREST\Flask01\Scripts\Activate.ps1
激活虚拟环境后,命令提示符将显示虚拟环境根文件夹名称,用括号括起来,作为默认提示的前缀,以提醒我们我们正在虚拟环境中工作。在这种情况下,我们将看到 (Flask01) 作为命令提示符的前缀,因为激活的虚拟环境的根文件夹是 Flask01。
我们已创建并激活了虚拟环境。现在是时候运行将在 macOS、Linux 或 Windows 上相同的命令了;我们必须运行以下命令使用 pip 安装 Flask-RESTful。Flask 是 Flask-RESTful 的依赖项,因此 pip 也会自动安装它:
pip install flask-restful
输出的最后几行将指示所有成功安装的包,包括 flask-restful 和 Flask:
Installing collected packages: six, pytz, click, itsdangerous, MarkupSafe, Jinja2, Werkzeug, Flask, python-dateutil, aniso8601, flask-restful
Running setup.py install for click
Running setup.py install for itsdangerous
Running setup.py install for MarkupSafe
Running setup.py install for aniso8601
Successfully installed Flask-0.11.1 Jinja2-2.8 MarkupSafe-0.23 Werkzeug-0.11.10 aniso8601-1.1.0 click-6.6 flask-restful-0.3.5 itsdangerous-0.24 python-dateutil-2.5.3 pytz-2016.4 six-1.10.0
声明响应的状态码
无论是 Flask 还是 Flask-RESTful 都没有包含不同 HTTP 状态码的变量声明。我们不希望返回数字作为状态码。我们希望我们的代码易于阅读和理解,因此,我们将使用描述性的 HTTP 状态码。我们将从 Django REST Framework 包含的 status.py 文件中借用声明与 HTTP 状态码相关的有用函数和变量的代码,即我们在前几章中使用过的框架。
首先,在最近创建的虚拟环境的根目录下创建一个名为 api 的文件夹,然后在 api 文件夹中创建一个新的 status.py 文件。以下行展示了从 rest_framework.status 模块借用的 api/models.py 文件中声明的具有描述性 HTTP 状态码的函数和变量的代码。我们不希望重新发明轮子,该模块提供了我们在基于 Flask 的 API 中处理 HTTP 状态码所需的一切。示例代码文件包含在 restful_python_chapter_05_01 文件夹中:
def is_informational(code):
return code >= 100 and code <= 199
def is_success(code):
return code >= 200 and code <= 299
def is_redirect(code):
return code >= 300 and code <= 399
def is_client_error(code):
return code >= 400 and code <= 499
def is_server_error(code):
return code >= 500 and code <= 599
HTTP_100_CONTINUE = 100
HTTP_101_SWITCHING_PROTOCOLS = 101
HTTP_200_OK = 200
HTTP_201_CREATED = 201
HTTP_202_ACCEPTED = 202
HTTP_203_NON_AUTHORITATIVE_INFORMATION = 203
HTTP_204_NO_CONTENT = 204
HTTP_205_RESET_CONTENT = 205
HTTP_206_PARTIAL_CONTENT = 206
HTTP_300_MULTIPLE_CHOICES = 300
HTTP_301_MOVED_PERMANENTLY = 301
HTTP_302_FOUND = 302
HTTP_303_SEE_OTHER = 303
HTTP_304_NOT_MODIFIED = 304
HTTP_305_USE_PROXY = 305
HTTP_306_RESERVED = 306
HTTP_307_TEMPORARY_REDIRECT = 307
HTTP_400_BAD_REQUEST = 400
HTTP_401_UNAUTHORIZED = 401
HTTP_402_PAYMENT_REQUIRED = 402
HTTP_403_FORBIDDEN = 403
HTTP_404_NOT_FOUND = 404
HTTP_405_METHOD_NOT_ALLOWED = 405
HTTP_406_NOT_ACCEPTABLE = 406
HTTP_407_PROXY_AUTHENTICATION_REQUIRED = 407
HTTP_408_REQUEST_TIMEOUT = 408
HTTP_409_CONFLICT = 409
HTTP_410_GONE = 410
HTTP_411_LENGTH_REQUIRED = 411
HTTP_412_PRECONDITION_FAILED = 412
HTTP_413_REQUEST_ENTITY_TOO_LARGE = 413
HTTP_414_REQUEST_URI_TOO_LONG = 414
HTTP_415_UNSUPPORTED_MEDIA_TYPE = 415
HTTP_416_REQUESTED_RANGE_NOT_SATISFIABLE = 416
HTTP_417_EXPECTATION_FAILED = 417
HTTP_428_PRECONDITION_REQUIRED = 428
HTTP_429_TOO_MANY_REQUESTS = 429
HTTP_431_REQUEST_HEADER_FIELDS_TOO_LARGE = 431
HTTP_451_UNAVAILABLE_FOR_LEGAL_REASONS = 451
HTTP_500_INTERNAL_SERVER_ERROR = 500
HTTP_501_NOT_IMPLEMENTED = 501
HTTP_502_BAD_GATEWAY = 502
HTTP_503_SERVICE_UNAVAILABLE = 503
HTTP_504_GATEWAY_TIMEOUT = 504
HTTP_505_HTTP_VERSION_NOT_SUPPORTED = 505
HTTP_511_NETWORK_AUTHENTICATION_REQUIRED = 511
代码声明了五个函数,这些函数接收 HTTP 状态码作为code参数,并确定状态码属于以下哪个类别:信息性、成功、重定向、客户端错误或服务器错误类别。当我们需要返回特定的状态码时,我们将使用前面的变量。例如,如果我们需要返回404 Not Found状态码,我们将返回status.HTTP_404_NOT_FOUND,而不是仅仅404。
创建模型
现在,我们将创建一个简单的MessageModel类,我们将使用它来表示消息。请记住,我们不会在数据库中持久化模型,因此在这种情况下,我们的类将只提供所需的属性而没有映射信息。在api文件夹中创建一个新的models.py文件。以下行显示了在api/models.py文件中创建MessageModel类的代码。示例的代码文件包含在restful_python_chapter_05_01文件夹中:
class MessageModel:
def __init__(self, message, duration, creation_date, message_category):
# We will automatically generate the new id
self.id = 0
self.message = message
self.duration = duration
self.creation_date = creation_date
self.message_category = message_category
self.printed_times = 0
self.printed_once = False
MessageModel类仅声明了一个构造函数,即__init__方法。此方法接收许多参数,然后使用它们来初始化具有相同名称的属性:message、duration、creation_date和message_category。id属性设置为 0,printed_times设置为0,printed_once设置为False。我们将自动通过 API 调用为每个新生成的消息递增标识符。
使用字典作为存储库
现在,我们将创建一个MessageManager类,我们将使用它将MessageModel实例持久化到内存字典中。我们的 API 方法将调用MessageManager类的相关方法来检索、插入、更新和删除MessageModel实例。在api文件夹中创建一个新的api.py文件。以下行显示了在api/api.py文件中创建MessageManager类的代码。此外,以下行声明了我们将需要用于此文件中所有代码的所有导入。示例的代码文件包含在restful_python_chapter_05_01文件夹中。
from flask import Flask
from flask_restful import abort, Api, fields, marshal_with, reqparse, Resource
from datetime import datetime
from models import MessageModel
import status
from pytz import utc
class MessageManager():
last_id = 0
def __init__(self):
self.messages = {}
def insert_message(self, message):
self.__class__.last_id += 1
message.id = self.__class__.last_id
self.messages[self.__class__.last_id] = message
def get_message(self, id):
return self.messages[id]
def delete_message(self, id):
del self.messages[id]
MessageManager类声明了一个last_id类属性,并将其初始化为 0。这个类属性存储了最后生成的并分配给存储在字典中的MessageModel实例的 id。构造函数,即__init__方法,创建并初始化messages属性为一个空字典。
代码为该类声明了以下三个方法:
-
insert_message:此方法接收一个最近创建的MessageModel实例,作为message参数。代码增加last_id类属性的值,然后将结果值分配给接收到的消息的 id。代码使用self.__class__来引用当前实例的类型。最后,代码将message作为值添加到由生成的 id,即last_id,标识的self.messages字典中的键。 -
get_message: 此方法接收要从中检索的self.messages字典中消息的id。代码返回与接收到的id匹配的键在self.messages字典中我们用作数据源的相关值。 -
delete_message: 此方法接收要从中移除的self.messages字典中消息的id。代码删除了与接收到的id匹配的键值对,该键值对位于我们用作数据源的self.messages字典中。
我们不需要一个更新消息的方法,因为我们只需更改已存储在 self.messages 字典中的 MessageModel 实例的属性。字典中存储的值是对我们正在更新的 MessageModel 实例的引用,因此我们不需要调用特定的方法来更新字典中的实例。然而,如果我们正在与数据库一起工作,我们需要调用我们的 ORM 或数据仓库的更新方法。
配置输出字段
现在,我们将创建一个 message_fields 字典,我们将使用它来控制我们想要 Flask-RESTful 在响应中渲染的数据。打开之前创建的 api/api.py 文件,并添加以下行。示例的代码文件包含在 restful_python_chapter_05_01 文件夹中。
message_fields = {
'id': fields.Integer,
'uri': fields.Url('message_endpoint'),
'message': fields.String,
'duration': fields.Integer,
'creation_date': fields.DateTime,
'message_category': fields.String,
'printed_times': fields.Integer,
'printed_once': fields.Boolean
}
message_manager = MessageManager()
我们声明了 message_fields 字典(dict),其中包含 flask_restful.fields 模块中声明的字符串和类的键值对。键是我们想要从 MessageModel 类中渲染的属性名称,值是格式化和返回字段值的类。在前面的代码中,我们使用了以下类,这些类格式化和返回键中指定的字段值:
-
field.Integer: 输出一个整数值。 -
fields.Url: 生成一个 URL 的字符串表示。默认情况下,此类为请求的资源生成相对 URI。代码指定了'message_endpoint'作为endpoint参数。这样,该类将使用指定的端点名称。我们将在api.py文件中稍后声明此端点。我们不希望在生成的 URI 中包含主机名,因此我们使用absolute布尔属性的默认值,即False。 -
fields.DateTime: 输出 UTC 格式的格式化datetime字符串,默认采用 RFC 822 格式。 -
fields.Boolean: 生成一个布尔值的字符串表示。
'uri' 字段使用 fields.Url,它与指定的端点相关联,而不是与 MessageModel 类的属性相关联。这是唯一一个指定的字段名在 MessageModel 类中没有属性的情况。其他指定为键的字符串表示我们在使用 message_fields 字典来构建最终的序列化响应输出时想要渲染的所有属性。
在我们声明了message_fields字典之后,下一行代码创建了一个名为message_manager的之前创建的MessageManager类实例。我们将使用此实例来创建、检索和删除MessageModel实例。
在 Flask 可插拔视图之上进行资源路由操作
Flask-RESTful 使用基于 Flask 可插拔视图的资源作为构建 RESTful API 的主要构建块。我们只需要创建一个flask_restful.Resource类的子类,并声明每个支持的 HTTP 动词的方法。flask_restful.Resource的子类代表 RESTful 资源,因此,我们将必须声明一个类来表示消息集合,另一个类来表示消息资源。
首先,我们将创建一个Message类,我们将使用它来表示消息资源。打开之前创建的api/api.py文件,并添加以下行。示例代码文件包含在restful_python_chapter_05_01文件夹中,如下所示:
class Message(Resource):
def abort_if_message_doesnt_exist(self, id):
if id not in message_manager.messages:
abort(
status.HTTP_404_NOT_FOUND,
message="Message {0} doesn't exist".format(id))
@marshal_with(message_fields)
def get(self, id):
self.abort_if_message_doesnt_exist(id)
return message_manager.get_message(id)
def delete(self, id):
self.abort_if_message_doesnt_exist(id)
message_manager.delete_message(id)
return '', status.HTTP_204_NO_CONTENT
@marshal_with(message_fields)
def patch(self, id):
self.abort_if_message_doesnt_exist(id)
message = message_manager.get_message(id)
parser = reqparse.RequestParser()
parser.add_argument('message', type=str)
parser.add_argument('duration', type=int)
parser.add_argument('printed_times', type=int)
parser.add_argument('printed_once', type=bool)
args = parser.parse_args()
if 'message' in args:
message.message = args['message']
if 'duration' in args:
message.duration = args['duration']
if 'printed_times' in args:
message.printed_times = args['printed_times']
if 'printed_once' in args:
message.printed_once = args['printed_once']
return message
Message类是flask_restful.Resource的子类,并声明了以下三个方法,当在表示的资源上接收到与同名 HTTP 方法请求时将被调用:
-
get: 此方法通过id参数接收要检索的消息的 ID。代码调用self.abort_if_message_doesnt_exist方法,如果请求的 ID 没有消息则终止。如果消息存在,代码将返回由message_manager.get_message方法返回的与指定id匹配的MessageModel实例。get方法使用@marshal_with装饰器,并将message_fields作为参数。装饰器将获取MessageModel实例,并应用message_fields中指定的字段过滤和输出格式。 -
delete: 此方法通过id参数接收要删除的消息的 ID。代码调用self.abort_if_message_doesnt_exist方法以终止,如果请求的 ID 没有消息。如果存在py````pymessage exists, the code calls themessage_manager.delete_messagemethod with the received id as an argument to remove theMessageModelinstance from our data repository. Then, the code returns an empty response body and a204 No Content` status code.
patch: This method receives the id of the message that has to be updated or patched in theidargument. The code calls theself.abort_if_message_doesnt_existmethod to abort in case there is no message with the requested id. In case the message exists, the code saves theMessageModelinstance whoseidthat matches the specifiedidreturned by themessage_manager.get_messagemethod in themessagevariable. The next line creates aflask_restful.reqparse.RequestParserinstance namedparser. TheRequestParserinstance allows us to add arguments with their names and types and then easily parse the arguments received with the request. The code makes four calls to theparser.add_argumentwith the argument name and the type of the four arguments we want to parse. Then, the code calls theparser.parse_argsmethod to parse all the arguments from the request and saves the returned dictionary (dict) in theargsvariable. The code updates all the attributes that have new values in theargsdictionary in theMessageModelinstance:message. In case the request didn't include values for certain fields, the code won't make changes to the realted attributes. The request doesn't require to include the four fields that can be updated with values. The code returns the updatedmessage. Thepatchmethod uses the@marshal_withdecorator withmessage_fieldsas an argument. The decorator will take theMessageModelinstance,message, and apply the field filtering and output formatting specified inmessage_fields.
Tip
We used multiple return values to set the response code.
As previously explained, the three methods call the internal abort_if_message_doesnt_exist method that receives the id for an existing MessageModel instance in the id argument. If the received id is not present in the keys of the message_manager.messages dictionary, the method calls the flask_restful.abort function with status.HTTP_404_NOT_FOUND as the http_status_code argument and a message indicating that the message with the specified id doesn't exists. The abort function raises an HTTPException for the received http_status_code and attaches the additional keyword arguments to the exception for later processing. In this case, we generate an HTTP 404 Not Found status code.
Both the get and patch methods use the @marshal_with decorator that takes a single data object or a list of data objects and applies the field filtering and output formatting specifies as an argument. The marshalling can also work with dictionaries (dicts). In both methods, we specified message_fields as an argument, and therefore, the code renders the following fields: id, uri, message, duration, creation_date, message_category, printed_times and printed_once. When we use the @marshal_with decorator, we are automatically returning an HTTP 200 OK status code.
The following return statement with the @marshal_with(message_fields) decorator returns an HTTP 200 OK status code because we didn't specify any status code after the returned object (message):
return message
```py
The next line is the line of code that is really executed with the `@marshal_with(message_fields)` decorator, and we can use it instead of working with the decorator:
return marshal(message, resource_fields), status.HTTP_200_OK
For example, we can call the `marshal` function as shown in the previous line instead of using the `@marshal_with` decorator and the code will produce the same result.
Now, we will create a `MessageList` class that we will use to represent the collection of messages. Open the previously created `api/api.py` file and add the following lines. The code file for the sample is included in the `restful_python_chapter_05_01` folder:
class MessageList(Resource):
@marshal_with(message_fields)
def get(self):
return [v for v in message_manager.messages.values()]
@marshal_with(message_fields)
def post(self):
parser = reqparse.RequestParser()
parser.add_argument('message', type=str, required=True, help='消息不能为空!')
parser.add_argument('duration', type=int, required=True, help='持续时间不能为空!')
parser.add_argument('message_category', type=str, required=True, help='消息类别不能为空!')
args = parser.parse_args()
message = MessageModel(
message=args['message'],
duration=args['duration'],
creation_date=datetime.now(utc),
message_category=args['message_category']
)
message_manager.insert_message(message)
return message, status.HTTP_201_CREATED
The `MessageList` class is a subclass of `flask_restful.Resource` and declares the following two methods that will be called when the HTTP method with the same name arrives as a request on the represented resource:
* `get`: This method returns a list with all the `MessageModel` instances saved in the `message_manager.messages` dictionary. The `get` method uses the `@marshal_with` decorator with `message_fields` as an argument. The decorator will take each `MessageModel` instance in the returned list and apply the field filtering and output formatting specified in `message_fields`.
* `post`: This method creates a `flask_restful.reqparse.RequestParser` instance named `parser`. The `RequestParser` instance allows us to add arguments with their names and types and then easily parse the arguments received with the `POST` request to create a new `MessageModel` instance. The code makes three calls to the `parser.add_argument` with the argument name and the type of the three arguments we want to parse. Then, the code calls the `parser.parse_args` method to parse all the arguments from the request and saves the returned dictionary (`dict`) in the `args` variable. The code uses the parsed arguments in the dictionary to specify the values for the `message`, `duration` and `message_category` attributes to create a new `MessageModel` instance and save it in the `message` variable. The value for the `creation_date` argument is set to the current `datetime` with time zone info, and therefore, it isn't parsed from the request. Then, the code calls the `message_manager.insert_message` method with the new `MessageModel` instance (`message`) to add this new instance to the dictionary. The `post` method uses the `@marshal_with` decorator with `message_fields` as an argument. The decorator will take the recently created and stored `MessageModel` instance, `message`, and apply the field filtering and output formatting specified in `message_fields`. The code returns an HTTP `201 Created` status code.
The following table shows the method of our previously created classes that we want to be executed for each combination of HTTP verb and scope:
| **HTTP verb** | **Scope** | **Class and method** |
| `GET` | Collection of messages | MessageList.get |
| `GET` | Message | Message.get |
| `POST` | Collection of messages | MessageList.post |
| `PATCH` | Message | Message.patch |
| `DELETE` | Message | Message.delete |
If the request results in the invocation of a resource with an unsupported HTTP method, Flask-RESTful will return a response with the HTTP `405 Method Not Allowed` status code.
# Configuring resource routing and endpoints
We must make the necessary resource routing configurations to call the appropriate methods and pass them all the necessary arguments by defining URL rules. The following lines create the main entry point for the application, initialize it with a Flask application and configure the resource routing for the `api`. Open the previously created `api/api.py` file and add the following lines. The code file for the sample is included in the `restful_python_chapter_05_01` folder:
app = Flask(name)
api = Api(app)
api.add_resource(MessageList, '/api/messages/')
api.add_resource(Message, '/api/messages/int:id', endpoint='message_endpoint')
if name == 'main':
app.run(debug=True)
The code creates an instance of the `flask_restful.Api` class and saves it in the `api` variable. Each call to the `api.add_resource` method routes a URL to a resource, specifically to one of the previously declared subclasses of the `flask_restful.Resource` class. When there is a request to the API and the URL matches one of the URLs specified in the `api.add_resource` method, Flask will call the method that matches the HTTP verb in the request for the specified class. The method follows standard Flask routing rules.
For example, the following line will make an HTTP GET request to `/api/messages/` without any additional parameters to call the `MessageList.get` method:
api.add_resource(MessageList, '/api/messages/')
Flask will pass the URL variables to the called method as arguments. For example, the following line will make an HTTP `GET` request to `/api/messages/12` to call the `Message.get` method with `12` passed as the value for the `id` argument:
api.add_resource(Message, '/api/messages/int:id', endpoint='message_endpoint')
In addition, we can specify a string value for the endpoint argument to make it easy to reference the specified route in `fields.Url` fields. We pass the same endpoint name, `'message_endpoint'` as an argument in the `uri` field declared as `fields.Url` in the `message_fields` dictionary that we use to render each `MessageModel` instance. This way, `fields.Url` will generate a URI considering this route.
We just required a few lines of code to configure resource routing and endpoints. The last line just calls the `app.run` method to start the Flask application with the `debug` argument set to `True` to enable debugging. In this case, we start the application by calling the `run` method to immediately launch a local server. We could also achieve the same goal by using the `flask` command-line script. However, this option would require us to configure environment variables and the instructions are different for the platforms that we are covering in this book-macOS, Windows and Linux.
### Tip
As with any other Web framework, you should never enable debugging in a production environment.
# Making HTTP requests to the Flask API
Now, we can run the `api/api.py` script that launches Flask's development server to compose and send HTTP requests to our unsecure and simple Web API (we will definitely add security later). Execute the following command.
python api/api.py
The following lines show the output after we execute the previous command. The development server is listening at port `5000`.
-
在 http://127.0.0.1:5000/ 上运行(按 CTRL+C 退出)
-
使用 stat 重启
-
调试器处于活动状态!
-
调试器密码:294-714-594
With the previous command, we will start Flask development server and we will only be able to access it in our development computer. The previous command starts the development server in the default IP address, that is, `127.0.0.1` (`localhost`). It is not possible to access this IP address from other computers or devices connected on our LAN. Thus, if we want to make HTTP requests to our API from other computers or devices connected to our LAN, we should use the development computer IP address, `0.0.0.0` (for IPv4 configurations) or `::` (for IPv6 configurations), as the desired IP address for our development server.
If we specify `0.0.0.0` as the desired IP address for IPv4 configurations, the development server will listen on every interface on port 5000\. In addition, it is necessary to open the default port `5000` in our firewalls (software and/or hardware) and configure port-forwarding to the computer that is running the development server.
We just need to specify `'0.0.0.0'` as the value for the host argument in the call to the `app.run` method, specifically, the last line in the `api/api.py` file. The following line shows the new call to `app.run` that launches Flask's development server in an IPv4 configuration and allows requests to be made from other computers and devices connected to our LAN. The line generates an externally visible server. The code file for the sample is included in the `restful_python_chapter_05_02` folder:
if name == 'main':
app.run(host='0.0.0.0', debug=True)
### Tip
If you decide to compose and send HTTP requests from other computers or devices connected to the LAN, remember that you have to use the development computer's assigned IP address instead of `localhost`. For example, if the computer's assigned IPv4 IP address is `192.168.1.103`, instead of `localhost:5000`, you should use `192.168.1.103:5000`. Of course, you can also use the host name instead of the IP address. The previously explained configurations are very important because mobile devices might be the consumers of our RESTful APIs and we will always want to test the apps that make use of our APIs in our development environments. In addition, we can work with useful tools such as ngrok that allow us to generate secure tunnels to localhost. You can read more information about ngrok at [`www.ngrok.com`](http://www.ngrok.com).
The Flask development server is running on localhost (`127.0.0.1`), listening on port `5000`, and waiting for our HTTP requests. Now, we will compose and send HTTP requests locally in our development computer or from other computer or devices connected to our LAN.
## Working with command-line tools â curl and httpie
We will start composing and sending HTTP requests with the command-line tools we have introduced in *Chapter 1* , *Developing RESTful APIs with Django*, curl and HTTPie. In case you haven't installed HTTPie, make sure you activate the virtual environment and then run the following command in the terminal or command prompt to install the HTTPie package.
pip install --upgrade httpie
### Tip
In case you don't remember how to activate the virtual environment that we created for this example, read the following section in this chapter-*Setting up the virtual environment with Django REST framework*.
Open a Cygwin Terminal in Windows or a Terminal in macOS or Linux, and run the following command. It is very important that you enter the ending slash (`/`) when specified /api/messages won't match any of the configured URL routes. Thus, we must enter `/api/messages/`, including the ending slash (/). We will compose and send an HTTP request to create a new message:
http POST :5000/api/messages/ message='欢迎来到物联网' duration=10 message_category='信息'
The following is the equivalent curl command. It is very important to use the `-H "Content-Type: application/json"` option to indicate curl to send the data specified after the `-d` option as `application/json` instead of the default `application/x-www-form-urlencoded`:
curl -iX POST -H "Content-Type: application/json" -d '{"message":"测量环境温度", "duration":10, "message_category": "信息"}' :5000/api/messages/
The previous commands will compose and send the following HTTP request: `POST http://localhost:5000/api/messages/` with the following JSON key-value pairs:
{
"message": "欢迎来到物联网",
"duration": 10,
"message_category": "信息"
}
The request specifies `/api/messages/`, and therefore, it will match `'/api/messages/'` and run the `MessageList.post` method. The method doesn't receive arguments because the URL route doesn't include any parameters. As the HTTP verb for the request is `POST`, Flask calls the `post` method. If the new `MessageModel` was successfully persisted in the dictionary, the function returns an `HTTP 201 Created` status code and the recently persisted `MessageModel` serialized serialized to JSON in the response body. The following lines show an example response for the HTTP request, with the new `MessageModel` object in the JSON response:
HTTP/1.0 201 CREATED
Content-Length: 245
Content-Type: application/json
Date: Wed, 20 Jul 2016 04:43:24 GMT
Server: Werkzeug/0.11.10 Python/3.5.1
{
"creation_date": "Wed, 20 Jul 2016 04:43:24 -0000",
"duration": 10,
"id": 1,
"message": "欢迎来到物联网",
"message_category": "信息",
"printed_once": false,
"printed_times": 0,
"uri": "/api/messages/1"
}
We will compose and send an HTTP request to create another message. Go back to the Cygwin terminal in Windows or the Terminal in macOS or Linux, and run the following command:
http POST :5000/api/messages/ message='测量环境温度' duration=5 message_category='信息'
The following is the equivalent `curl` command:
curl -iX POST -H "Content-Type: application/json" -d '{"message":"测量环境温度", "duration":5, "message_category": "信息"}' :5000/api/messages/
The previous commands will compose and send the following HTTP request, `POST http://localhost:5000/api/messages/`, with the following JSON key-value pairs:
{
"message": "测量环境温度",
"duration": 5,
"message_category": "信息"
}
The following lines show an example response for the HTTP request, with the new `MessageModel` object in the JSON response:
HTTP/1.0 201 CREATED
Content-Length: 259
Content-Type: application/json
Date: Wed, 20 Jul 2016 18:27:05 GMT
Server: Werkzeug/0.11.10 Python/3.5.1
{
"creation_date": "Wed, 20 Jul 2016 18:27:05 -0000",
"duration": 5,
"id": 2,
"message": "测量环境温度",
"message_category": "信息",
"printed_once": false,
"printed_times": 0,
"uri": "/api/messages/2"
}
We will compose and send an HTTP request to retrieve all the messages. Go back to the Cygwin terminal in Windows or the Terminal in macOS or Linux, and run the following command:
http :5000/api/messages/
The following is the equivalent curl command:
curl -iX GET -H :5000/api/messages/
The previous commands will compose and send the following HTTP request: `GET http://localhost:5000/api/messages/`. The request specifies `/api/messages/`, and therefore, it will match `'/api/messages/'` and run the `MessageList.get` method. The method doesn't receive arguments because the URL route doesn't include any parameters. As the HTTP verb for the request is `GET`, Flask calls the `get` method. The method retrieves all the `MessageModel` objects and generates a JSON response with all of these `MessageModel` objects serialized.
The following lines show an example response for the HTTP request. The first lines show the HTTP response headers, including the status (200 OK) and the Content-type (application/json). After the HTTP response headers, we can see the details for the two `MessageModel` objects in the JSON response:
HTTP/1.0 200 OK
Content-Length: 589
Content-Type: application/json
Date: Wed, 20 Jul 2016 05:32:28 GMT
Server: Werkzeug/0.11.10 Python/3.5.1
[
{
"creation_date": "Wed, 20 Jul 2016 05:32:06 -0000",
"duration": 10,
"id": 1,
"message": "欢迎来到物联网",
"message_category": "信息",
"printed_once": false,
"printed_times": 0,
"uri": "/api/messages/1"
},
{
"creation_date": "Wed, 20 Jul 2016 05:32:18 -0000",
"duration": 5,
"id": 2,
"message": "测量环境温度",
"message_category": "信息",
"printed_once": false,
"printed_times": 0,
"uri": "/api/messages/2"
}
]
After we run the three requests, we will see the following lines in the window that is running the Flask development server. The output indicates that the server received three HTTP requests, specifically two `POST` requests and one `GET` request with `/api/messages/` as the URI. The server processed the three HTTP requests, returned status code 201 for the first two requests and 200 for the last request:
127.0.0.1 - - [20/Jul/2016 02:32:06] "POST /api/messages/ HTTP/1.1" 201 -
127.0.0.1 - - [20/Jul/2016 02:32:18] "POST /api/messages/ HTTP/1.1" 201 -
127.0.0.1 - - [20/Jul/2016 02:32:28] "GET /api/messages/ HTTP/1.1" 200 -
The following image shows two Terminal windows side-by-side on macOS. The Terminal window at the left-hand side is running the Flask development server and displays the received and processed HTTP requests. The Terminal window at the right-hand side is running `http` commands to generate the HTTP requests. It is a good idea to use a similar configuration to check the output while we compose and send the HTTP requests:

Now, we will compose and send an HTTP request to retrieve a message that doesn't exist. For example, in the previous list, there is no message with an `id` value equal to `800`. Run the following command to try to retrieve this message. Make sure you use an `id` value that doesn't exist. We must make sure that the utilities display the headers as part of the response to see the returned status code:
http :5000/api/messages/800
The following is the equivalent `curl` command:
curl -iX GET :5000/api/messages/800
The previous commands will compose and send the following HTTP request: `GET http://localhost:5000/api/messages/800`. The request is the same than the previous one we have analyzed, with a different number for the `id` parameter. The server will run the `Message.get` method with `800` as the value for the `id` argument. The method will execute the code that retrieves the `MessageModel` object whose id matches the `id` value received as an argument. However, the first line in the `MessageList.get` method calls the `abort_if_message_doesnt_exist` method that won't find the id in the dictionary keys and it will call the `flask_restful.abort` function because there is no message with the specified `id` value. Thus, the code will return an HTTP `404 Not Found` status code. The following lines show an example header response for the HTTP request and the message included in the body. In this case, we just leave the default message. Of course, we can customize it based on our specific needs:
HTTP/1.0 404 NOT FOUND
Content-Length: 138
Content-Type: application/json
日期:Wed, 20 Jul 2016 18:08:04 GMT
Server: Werkzeug/0.11.10 Python/3.5.1
{
"message": "消息 800 不存在。您请求了这个 URI [/api/messages/800],但您是指 /api/messages/int:id 吗?"
}
Our API is able to update a single field for an existing resource, and therefore, we provide an implementation for the `PATCH` method. For example, we can use the `PATCH` method to update two fields for an existing message and set the value for its `printed_once` field to `true` and `printed_times` to `1`. We don't want to use the `PUT` method because this method is meant to replace an entire message. The `PATCH` method is meant to apply a delta to an existing message, and therefore, it is the appropriate method to just change the value of the `printed_once` and `printed_times` fields.
Now, we will compose and send an HTTP request to update an existing message, specifically, to update the value of two fields. Make sure you replace `2` with the id of an existing message in your configuration:
http PATCH :5000/api/messages/2 printed_once=true printed_times=1
The following is the equivalent `curl` command:
curl -iX PATCH -H "Content-Type: application/json" -d '{"printed_once":"true", "printed_times":1}' :5000/api/messages/2
The previous command will compose and send a `PATCH` HTTP request with the specified JSON key-value pairs. The request has a number after `/api/messages/`, and therefore, it will match `'/api/messages/<int:id>'` and run the `Message.patch` method, that is, the `patch` method for the `Message` class. If a `MessageModel` instance with the specified id exists and it was successfully updated, the call to the method will return an HTTP `200 OK` status code and the recently updated `MessageModel` instance serialized to JSON in the response body. The following lines show a sample response:
HTTP/1.0 200 OK
Content-Length: 231
Content-Type: application/json
日期:Wed, 20 Jul 2016 18:28:01 GMT
Server: Werkzeug/0.11.10 Python/3.5.1
{
"creation_date": "Wed, 20 Jul 2016 18:27:05 -0000",
"duration": 0,
"id": 2,
"message": "测量环境温度",
"message_category": "Information",
"printed_once": true,
"printed_times": 1,
"uri": "/api/messages/2"
}
### Tip
The IoT device will make the previously explained HTTP request when it displays the message for the first time. Then, it will make additional PATCH requests to update the value for the `printed_times` field.
Now, we will compose and send an HTTP request to delete an existing message, specifically, the last message we added. As happened in our last HTTP requests, we have to check the value assigned to `id` in the previous response and replace `2` in the command with the returned value:
http DELETE :5000/api/messages/2
The following is the equivalent `curl` command:
curl -iX DELETE :5000/api/messages/2
The previous commands will compose and send the following HTTP request: `DELETE http://localhost:5000/api/messages/2`. The request has a number after `/api/messages/`, and therefore, it will match `'/api/messages/<int:id>'` and run the `Message.delete` method, that is, the `delete` method for the `Message` class. If a `MessageModel` instance with the specified id exists and it was successfully deleted, the call to the method will return an HTTP `204 No Content` status code. The following lines show a sample response:
HTTP/1.0 204 NO CONTENT
Content-Length: 0
Content-Type: application/json
日期:Wed, 20 Jul 2016 18:50:12 GMT
Server: Werkzeug/0.11.10 Python/3.5.1
## Working with GUI tools - Postman and others
So far, we have been working with two terminal-based or command-line tools to compose and send HTTP requests to our Flask development server-cURL and HTTPie. Now, we will work with one of the GUI tools we used when composing and sending HTTP requests to the Django development server-Postman.
Now, we will use the **Builder** tab in Postman to easily compose and send HTTP requests to `localhost:5000` and test the RESTful API with this GUI tool. Remember that Postman doesn't support curl-like shorthands for localhost, and therefore, we cannot use the same shorthands we have been using when composing requests with curl and HTTPie.
Select **GET** in the dropdown menu at the left-hand side of the **Enter request URL** textbox, and enter `localhost:5000/api/messages/` in this textbox at the right-hand side of the dropdown. Then, click **Send** and Postman will display the Status (**200 OK**), the time it took for the request to be processed and the response body with all the games formatted as JSON with syntax highlighting (**Pretty** view). The following screenshot shows the JSON response body in Postman for the HTTP GET request.

Click on **Headers** at the right-hand side of **Body** and **Cookies** to read the response headers. The following screenshot shows the layout for the response headers that Postman displays for the previous response. Notice that Postman displays the **Status** at the right-hand side of the response and doesn't include it as the first line of the Headers, as happened when we worked with both the cURL and HTTPie utilities:

Now, we will use the **Builder** tab in Postman to compose and send an HTTP request to create a new message, specifically, a POST request. Follow the next steps:
1. Select **POST** in the drop-down menu at the left-hand side of the **Enter request URL** textbox, and enter `localhost:5000/api/messages/` in this textbox at the right-hand side of the dropdown.
2. Click **Body** at the right-hand side of **Authorization** and **Headers**, within the panel that composes the request.
3. Activate the **raw** radio button and select **JSON (application/json)** in the dropdown at the right-hand side of the **binary** radio button. Postman will automatically add a **Content-type** = **application/json** header, and therefore, you will notice the **Headers** tab will be renamed to **Headers (1)**, indicating us that there is one key-value pair specified for the request headers.
4. Enter the following lines in the textbox below the radio buttons, within the **Body** tab:
{
"message": "测量距离",
"duration": 5,
"message_category": "Information"
}
以下截图显示了 Postman 中的请求体:

我们遵循了必要的步骤来创建一个带有 JSON 体的 HTTP POST 请求,该请求指定了创建新游戏所需的关键字段值对。点击**发送**,Postman 将显示状态(**201 已创建**),请求处理所需的时间以及以格式化 JSON 并具有语法高亮(**美化**视图)的响应体。以下截图显示了 Postman 中 HTTP POST 请求的 JSON 响应体:

### 小贴士
如果我们想使用 Postman 为我们的 API 编写并发送 HTTP PATCH 请求,必须遵循之前解释的步骤,在请求体中提供 JSON 数据。
点击或轻触 JSON 响应体中 url 字段的值 -`/api/messages/2`。当您将鼠标指针悬停在它上面时,您会注意到值会被下划线。Postman 将自动生成一个到 `localhost:5000/api/messages/2` 的 `GET` 请求。点击**发送**来运行它并检索最近添加的消息。该字段对于使用 Postman 等工具浏览 API 很有用。
由于我们对生成外部可见的 Flask 开发服务器进行了必要的更改,我们还可以使用能够从移动设备编写并发送 HTTP 请求的应用程序来与 RESTful API 一起工作。例如,我们可以在 iPad Pro 和 iPhone 等 iOS 设备上使用 iCurlHTTP 应用程序。在 Android 设备上,我们可以使用之前介绍的 HTTP Request 应用程序。
以下截图显示了使用 iCurlHTTP App 组合和发送以下 HTTP 请求的结果:`GET http://192.168.2.3:5000/api/messages/`。请记住,你必须在你的 LAN 和路由器中执行之前解释的配置,才能从连接到你的 LAN 的其他设备访问 Flask 开发服务器。在这种情况下,运行 Flask Web 服务器的计算机分配的 IP 地址是`192.168.2.3`,因此,你必须将此 IP 替换为分配给你的开发计算机的 IP 地址。

# 测试你的知识
1. Flask-RESTful 使用以下哪个作为构建 RESTful API 的主要构建块?
1. 基于 Flask 可插拔视图构建的资源
1. 基于 Flask 资源视图构建的状态。
1. 基于 Flask 可插拔控制器的资源。
1. 为了能够处理资源上的 HTTP POST 请求,我们必须在`flask_restful.Resource`的子类中声明一个具有以下名称的方法。
1. `post_restful`
1. `post_method`
1. `post`
1. 为了能够处理资源上的 HTTP `GET`请求,我们必须在`flask_restful.Resource`的子类中声明一个具有以下名称的方法。
1. `get_restful`
1. `get_method`
1. `get`
1. `flask_restful.Resource`的子类表示:
1. `一个控制器资源。`
1. `一个 RESTful 资源。`
1. `一个单一的 RESTful HTTP 动词。`
1. 如果我们使用`@marshal_with`装饰器并将`message_fields`作为参数,装饰器将:
1. 将`message_fields`中指定的字段过滤和输出格式应用于适当的实例。
1. 将`message_fields`中指定的字段过滤应用于适当的实例,不考虑输出格式。
1. 将`message_fields`中指定的输出格式应用于适当的实例,不考虑字段过滤。
# 摘要
在本章中,我们设计了一个 RESTful API 来与一个简单的字典交互,该字典充当数据存储库,并使用消息执行 CRUD 操作。我们定义了我们 API 的要求,并理解了每个 HTTP 方法执行的任务。我们使用 Flask 和 Flask-RESTful 设置了虚拟环境。
我们创建了一个模型来表示和持久化消息。我们学会了使用 Flask-RESTful 中包含的功能配置消息的序列化为 JSON 表示。我们编写了代表资源并处理不同 HTTP 请求的类,并配置了 URL 模式以将 URL 路由到类。
最后,我们启动了 Flask 开发服务器,并使用命令行工具来组合和发送 HTTP 请求到我们的 RESTful API,并分析了我们的代码中如何处理每个 HTTP 请求。我们还使用 GUI 工具来组合和发送 HTTP 请求。
现在我们已经了解了如何结合 Flask 和 Flask-RESTful 创建 RESTful API 的基础知识,我们将通过利用 Flask-RESTful 和相关 ORM 包含的高级功能来扩展 RESTful Web API 的功能,这正是我们将在下一章中讨论的内容。
# 第六章。在 Flask 中使用模型、SQLAlchemy 和超链接 API 进行工作
在本章中,我们将扩展上一章开始构建的 RESTful API 的功能。我们将使用 SQLAlchemy 作为我们的 ORM 来与 PostgreSQL 数据库交互,并且我们将利用 Flask 和 Flask-RESTful 中包含的先进功能,这将使我们能够轻松组织代码以构建复杂的 API,如模型和蓝图。在本章中,我们将:
+ 设计一个 RESTful API 以与 PostgreSQL 数据库交互
+ 理解每个 HTTP 方法执行的任务
+ 安装包以简化我们的常见任务
+ 创建和配置数据库
+ 为模型编写包含其关系的代码
+ 使用模式来验证、序列化和反序列化模型
+ 将蓝图与资源路由相结合
+ 注册蓝图并运行迁移
+ 创建和检索相关资源
# 设计一个与 PostgreSQL 数据库交互的 RESTful API
到目前为止,我们的 RESTful API 已经在充当数据存储库的简单字典上执行了 CRUD 操作。现在,我们想要使用 Flask RESTful 创建一个更复杂的 RESTful API,以与必须允许我们处理分组到消息类别中的消息的数据库模型交互。在我们的上一个 RESTful API 中,我们使用一个字符串属性来指定消息的消息类别。在这种情况下,我们希望能够轻松检索属于特定消息类别的所有消息,因此,我们将有一个消息与消息类别之间的关系。
我们必须能够对不同的相关资源和资源集合执行 CRUD 操作。以下列表列举了我们将创建以表示模型的资源和类名:
+ 消息类别(`Category`模型)
+ 消息(`Message`模型)
消息类别(`Category`)只需要一个整数字符串名称,而对于消息(`Message`),我们需要以下数据:
+ 一个整数标识符
+ 一个指向消息类别(`Category`)的外键
+ 一个字符串消息
+ 将指示消息在 OLED 显示屏上打印时间的秒数
+ 创建日期和时间。时间戳将在将新消息添加到集合时自动添加
+ 一个指示消息在 OLED 显示屏上打印次数的整数计数器
+ 一个`bool`值,指示消息是否至少在 OLED 显示屏上打印过一次
### 小贴士
我们将利用与 Flask RESTful 和 SQLAlchemy 相关的许多包,这些包使得序列化和反序列化数据、执行验证以及将 SQLAlchemy 与 Flask 和 Flask RESTful 集成变得更加容易。
# 理解每个 HTTP 方法执行的任务
以下表格显示了我们的新 API 必须支持的方法的 HTTP 动词、作用域和语义。每个方法由一个 HTTP 动词、一个作用域以及所有方法对所有资源和集合都有明确定义的意义组成:
| **HTTP 动词** | **作用域** | **语义** |
| --- | --- | --- |
| `GET` | 消息类别集合 | 获取集合中所有存储的消息类别,并按名称升序排序返回它们。每个类别必须包括资源的完整 URL。每个类别必须包括一个包含属于该类别所有消息详细信息的列表。消息不必包括类别,以避免重复数据。 |
| `GET` | 消息类别 | 获取单个消息类别。该类别必须包括我们在检索消息类别集合时为每个类别解释的相同信息。 |
| `POST` | 消息类别集合 | 在集合中创建一个新的消息类别。 |
| `PATCH` | 消息类别 | 更新现有消息类别的名称。 |
| `DELETE` | 消息类别 | 删除现有的消息类别。 |
| `GET` | 消息集合 | 获取集合中所有存储的消息,按消息升序排序。每条消息必须包括其消息类别详情,包括访问相关资源的完整 URL。消息类别详情不必包括属于该类别的消息。消息必须包括访问资源的完整 URL。 |
| `GET` | 消息 | 获取单个消息。消息必须包括我们在检索消息集合时为每个消息解释的相同信息。 |
| `POST` | 消息集合 | 在集合中创建一个新的消息。 |
| `PATCH` | 消息 | 更新现有消息的以下字段:消息、持续时间、打印次数和打印一次。 |
| `DELETE` | 消息 | 删除一个现有的消息。 |
此外,我们的 RESTful API 必须支持所有资源及其集合的`OPTIONS`方法。我们将使用 SQLAlchemy 作为我们的 ORM,并将与 PostgreSQL 数据库一起工作。然而,如果你不想花时间安装 PostgreSQL,你可以使用 SQLAlchemy 支持的任何其他数据库,例如 MySQL。如果你想要最简单的数据库,你可以使用 SQLite。
在前面的表中,有许多方法和范围。以下列表列举了前面表中提到的每个范围的 URI,其中`{id}`需要替换为资源的数字 ID 或主键。正如前一个示例中发生的那样,我们希望我们的 API 在 URL 中区分集合和单个集合资源。当我们提到一个集合时,我们将使用斜杠(`/`)作为 URL 的最后一个字符,当我们提到集合的单个资源时,我们不会使用斜杠(`/`)作为 URL 的最后一个字符:
+ **消息类别集合**: `/categories/`
+ **消息类别**: `/category/{id}`
+ **消息集合**: `/messages/`
+ **消息**: `/message/{id}`
让我们假设 `http://localhost:5000/api/` 是 Flask 开发服务器上运行的 API 的 URL。我们必须使用以下 HTTP 动词 (`GET`) 和请求 URL (`http://localhost:5000/api/categories/`) 来组合和发送一个 HTTP 请求,以检索存储在集合中的所有消息类别。每个类别将包含一个列表,列出属于该类别的所有消息。
```py
GET http://localhost:5000/api/categories/
安装包以简化我们的常见任务
确保您已退出 Flask 的开发服务器。请记住,您只需在运行它的终端或命令提示符窗口中按 Ctrl + C 即可。现在,我们将安装许多额外的包。确保您已激活我们在上一章中创建并命名为 Flask01 的虚拟环境。如果您为处理此示例或下载了本书的示例代码创建了新的虚拟环境,请确保您安装了我们在上一示例中使用的包。
在激活虚拟环境后,现在是时候运行适用于 macOS、Linux 或 Windows 的相同命令了。我们可以使用单个命令使用 pip 安装所有必要的包。然而,我们将运行独立的命令,以便在特定安装失败时更容易检测到任何问题。
现在,我们必须运行以下命令使用 pip 安装 Flask-SQLAlchemy。Flask-SQLAlchemy 为 Flask 应用程序添加了对 SQLAlchemy ORM 的支持。此扩展简化了在 Flask 应用程序中执行常见 SQLAlchemy 任务。SQLAlchemy 是 Flask-SQLAlchemy 的依赖项,因此,pip 也会自动安装它:
pip install Flask-SQLAlchemy
输出的最后几行将指示所有成功安装的包,包括 SQLAlchemy 和 Flask-SQLAlchemy:
Installing collected packages: SQLAlchemy, Flask-SQLAlchemy
Running setup.py install for SQLAlchemy
Running setup.py install for Flask-SQLAlchemy
Successfully installed Flask-SQLAlchemy-2.1 SQLAlchemy-1.0.14
运行以下命令使用 pip 安装 Flask-Migrate。Flask-Migrate 使用 Alembic 包来处理 Flask 应用程序的 SQLAlchemy 数据库迁移。我们将使用 Flask-Migrate 来设置我们的 PostgreSQL 数据库。Flask-Script 是 Flask-Migrate 的一个依赖项,因此,pip 也会自动安装它。Flask-Script 为 Flask 添加了编写外部脚本的支持,包括设置数据库的脚本。
pip install Flask-Migrate
输出的最后几行将指示所有成功安装的包,包括 Flask-Migrate 和 Flask-Script。其他已安装的包是额外的依赖项:
Installing collected packages: Mako, python-editor, alembic, Flask-Script, Flask-Migrate
Running setup.py install for Mako
Running setup.py install for python-editor
Running setup.py install for alembic
Running setup.py install for Flask-Script
Running setup.py install for Flask-Migrate
Successfully installed Flask-Migrate-2.0.0 Flask-Script-2.0.5 Mako-1.0.4 alembic-0.8.7 python-editor-1.0.1
运行以下命令使用 pip 安装 marshmallow。Marshmallow 是一个轻量级库,用于将复杂的数据类型转换为原生 Python 数据类型,反之亦然。Marshmallow 提供了我们可以用来验证输入数据、将输入数据反序列化为应用级对象以及将应用级对象序列化为 Python 原始类型的模式:
pip install marshmallow
输出的最后几行将指示 marshmallow 已成功安装:
Installing collected packages: marshmallow
Successfully installed marshmallow-2.9.1
运行以下命令使用 pip 安装 Marshmallow-sqlalchemy。Marshmallow-sqlalchemy 提供了与之前安装的 marshmallow 验证、序列化和反序列化轻量级库的 SQLAlchemy 集成:
pip install marshmallow-sqlalchemy
输出的最后几行将指示marshmallow-sqlalchemy已成功安装:
Installing collected packages: marshmallow-sqlalchemy
Successfully installed marshmallow-sqlalchemy-0.10.0
最后,运行以下命令使用 pip 安装 Flask-Marshmallow。Flask-Marshmallow 将之前安装的marshmallow库与 Flask 应用程序集成,使得生成 URL 和超链接字段变得简单:
pip install Flask-Marshmallow
输出的最后几行将指示Flask-Marshmallow已成功安装:
Installing collected packages: Flask-Marshmallow
Successfully installed Flask-Marshmallow-0.7.0
创建和配置数据库
现在,我们将创建一个 PostgreSQL 数据库,我们将使用它作为 API 的存储库。如果你还没有在电脑或开发服务器上运行 PostgreSQL 数据库,你需要下载并安装它。你可以从其网页:www.postgresql.org下载并安装这个数据库管理系统。如果你在 macOS 上工作,Postgres.app提供了一个非常简单的方法来安装和使用 PostgreSQL 在这个操作系统上:postgresapp.com:
小贴士
你必须确保 PostgreSQL 的 bin 文件夹包含在PATH环境变量中。你应该能够从当前的终端或命令提示符中执行psql命令行工具。如果文件夹没有包含在 PATH 中,当你尝试安装psycopg2包时,你会收到一个错误,指示找不到pg_config文件。此外,你将不得不使用每个我们将要使用的 PostgreSQL 命令行工具的完整路径。
我们将使用 PostgreSQL 命令行工具创建一个名为messages的新数据库。如果你已经有一个同名 PostgreSQL 数据库,确保你在所有命令和配置中使用另一个名称。你可以使用任何 PostgreSQL GUI 工具执行相同的任务。如果你在 Linux 上开发,运行命令时必须以postgres用户身份。在 macOS 或 Windows 上运行以下命令以创建一个名为messages的新数据库。请注意,该命令不会产生任何输出:
createdb messages
在 Linux 上,运行以下命令以使用postgres用户:
sudo -u postgres createdb messages
现在,我们将使用psql命令行工具运行一些 SQL 语句来创建一个特定的用户,我们将在 Flask 中使用它,并为其分配必要的角色。在 macOS 或 Windows 上,运行以下命令来启动 psql:
psql
在 Linux 上,运行以下命令以使用postgres用户:
sudo -u psql
然后,运行以下 SQL 语句,最后输入 \q 以退出 psql 命令行工具。将 user_name 替换为您在新的数据库中希望使用的用户名,将 password 替换为您选择的密码。我们将在 Flask 配置中使用用户名和密码。如果您已经在 PostgreSQL 中使用特定的用户并且已经为该用户授予了数据库权限,则无需运行这些步骤。您将看到表示权限已授予的输出。
CREATE ROLE user_name WITH LOGIN PASSWORD 'password';
GRANT ALL PRIVILEGES ON DATABASE messages TO user_name;
ALTER USER user_name CREATEDB;
\q
必须安装 Psycopg 2 软件包(psycopg2)。此软件包是一个 Python-PostgreSQL 数据库适配器,SQLAlchemy 将使用它来与我们的新创建的 PostgreSQL 数据库交互。
一旦我们确认 PostgreSQL 的 bin 文件夹已包含在 PATH 环境变量中,我们只需运行以下命令来安装此软件包:
pip install psycopg2
输出的最后几行将指示 psycopg2 软件包已成功安装:
Collecting psycopg2
Installing collected packages: psycopg2
Running setup.py install for psycopg2
Successfully installed psycopg2-2.6.2
如果您正在使用我们为上一个示例创建的相同虚拟环境,api 文件夹已经存在。如果您创建一个新的虚拟环境,请在创建的虚拟环境根目录下创建一个名为 api 的文件夹。
在 api 文件夹内创建一个新的 config.py 文件。以下行显示了声明变量以确定 Flask 和 SQLAlchemy 配置的代码。SQL_ALCHEMY_DATABASE_URI 变量生成用于 PostgreSQL 数据库的 SQLAlchemy URI。
确保您在 DB_NAME 的值中指定了所需的数据库名称,并根据您的 PostgreSQL 配置配置用户、密码、主机和端口。如果您遵循了前面的步骤,请使用这些步骤中指定的设置。示例代码文件包含在 restful_python_chapter_06_01 文件夹中:
import os
basedir = os.path.abspath(os.path.dirname(__file__))
DEBUG = True
PORT = 5000
HOST = "127.0.0.1"
SQLALCHEMY_ECHO = False
SQLALCHEMY_TRACK_MODIFICATIONS = True
SQLALCHEMY_DATABASE_URI = "postgresql://{DB_USER}:{DB_PASS}@{DB_ADDR}/{DB_NAME}".format(DB_USER="user_name", DB_PASS="password", DB_ADDR="127.0.0.1", DB_NAME="messages")
SQLALCHEMY_MIGRATE_REPO = os.path.join(basedir, 'db_repository')
我们将指定之前创建的模块作为创建 Flask 应用的函数的参数。这样,我们有一个模块指定了所有不同的配置变量,另一个模块创建了一个 Flask 应用。我们将创建 Flask 应用工厂作为我们迈向新 API 的最后一步。
创建具有其关系的模型
现在,我们将创建可以用来表示和持久化消息类别、消息及其关系的模型。打开 api/models.py 文件,并用以下代码替换其内容。与其它模型相关的字段声明在代码列表中突出显示。如果您创建了一个新的虚拟环境,请在 api 文件夹中创建一个新的 models.py 文件。示例代码文件包含在 restful_python_chapter_06_01 文件夹中:
from marshmallow import Schema, fields, pre_load
from marshmallow import validate
from flask_sqlalchemy import SQLAlchemy
from flask_marshmallow import Marshmallow
db = SQLAlchemy()
ma = Marshmallow()
class AddUpdateDelete():
def add(self, resource):
db.session.add(resource)
return db.session.commit()
def update(self):
return db.session.commit()
def delete(self, resource):
db.session.delete(resource)
return db.session.commit()
class Message(db.Model, AddUpdateDelete):
id = db.Column(db.Integer, primary_key=True)
message = db.Column(db.String(250), unique=True, nullable=False)
duration = db.Column(db.Integer, nullable=False)
creation_date = db.Column(db.TIMESTAMP, server_default=db.func.current_timestamp(), nullable=False)
category_id = db.Column(db.Integer, db.ForeignKey('category.id', ondelete='CASCADE'), nullable=False)
category = db.relationship('Category', backref=db.backref('messages', lazy='dynamic' , order_by='Message.message'))
printed_times = db.Column(db.Integer, nullable=False, server_default='0')
printed_once = db.Column(db.Boolean, nullable=False, server_default='false')
def __init__(self, message, duration, category):
self.message = message
self.duration = duration
self.category = category
class Category(db.Model, AddUpdateDelete):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(150), unique=True, nullable=False)
def __init__(self, name):
self.name = name
首先,代码创建了一个名为 db 的 flask_sqlalchemy.SQLAlchemy 类的实例。这个实例将允许我们控制 Flask 应用程序的 SQLAlchemy 集成。此外,该实例将提供访问所有 SQLAlchemy 函数和类的方法。
然后,代码创建了一个名为 ma 的 flask_marshmallow.Marshmallow 类的实例。在创建 Marshmallow 实例之前创建 flask_sqlalchemy.SQLAlchemy 实例非常重要,因此在这种情况下,顺序很重要。Marshmallow 是一个包装类,它将 Mashmallow 与 Flask 应用程序集成。名为 ma 的实例将提供对 Schema 类、在 marshmallow.fields 中定义的字段以及声明在 flask_marshmallow.fields 中的 Flask 特定字段的访问。我们将在声明与我们的模型相关的模式时使用它们。
代码创建了一个名为 AddUpdateDelete 的类,该类声明了以下三个方法,通过 SQLAlchemy 会话添加、更新和删除资源:
-
add:此方法接收要添加的对象,并将其作为resource参数传递,然后使用接收到的资源作为参数调用db.session.add方法,在底层数据库中创建对象。最后,代码提交会话。 -
update:此方法只是提交会话,以持久化对底层数据库中对象所做的更改。 -
delete:此方法接收要删除的对象,并将其作为resource参数传递,然后使用接收到的资源作为参数调用db.session.delete方法,从底层数据库中删除对象。最后,代码提交会话。
代码声明了以下两个模型,具体来说,是两个类,作为 db.Model 和 AddUpdateDelete 类的子类:
-
Message -
Category
我们指定了许多属性的字段类型、最大长度和默认值。表示没有任何关系的字段属性是 db.Column 类的实例。两个模型都声明了一个 id 属性,并指定了 primary_key 参数的 True 值,以指示它是主键。SQLAlchemy 将使用这些数据在 PostgreSQL 数据库中生成必要的表。
Message 模型使用以下行声明了 category 字段:
category = db.relationship('Category', backref=db.backref('messages', lazy='dynamic', order_by='Message.message'))
上一行使用 db.relationship 函数提供了到 Category 模型的多对一关系。backref 参数指定了一个调用 db.backref 函数的调用,其中 'messages' 作为第一个值,表示从相关的 Category 对象返回到 Message 对象的关系名称。order_by 参数指定 'Message.message',因为我们希望每个类别的消息按消息字段的值升序排序。
两个模型都声明了一个构造函数,即 __init__ 方法。Message 模型的构造函数接收许多参数,并使用它们来初始化具有相同名称的属性:message、duration 和 category。Category 模型的构造函数接收一个 name 参数,并使用它来初始化具有相同名称的属性。
创建用于验证、序列化和反序列化模型的模式
现在,我们将创建 Flask-Marshmallow 模式,我们将使用这些模式来验证、序列化和反序列化之前声明的Category和Message模型及其关系。打开api/models.py文件,在现有行之后添加以下代码。与其它模式相关的字段声明在代码列表中被突出显示。示例的代码文件包含在restful_python_chapter_06_01文件夹中:
class CategorySchema(ma.Schema):
id = fields.Integer(dump_only=True)
name = fields.String(required=True, validate=validate.Length(3))
url = ma.URLFor('api.categoryresource', id='<id>', _external=True)
messages = fields.Nested('MessageSchema', many=True, exclude=('category',))
class MessageSchema(ma.Schema):
id = fields.Integer(dump_only=True)
message = fields.String(required=True, validate=validate.Length(1))
duration = fields.Integer()
creation_date = fields.DateTime()
category = fields.Nested(CategorySchema, only=['id', 'url', 'name'], required=True)
printed_times = fields.Integer()
printed_once = fields.Boolean()
url = ma.URLFor('api.messageresource', id='<id>', _external=True)
@pre_load
def process_category(self, data):
category = data.get('category')
if category:
if isinstance(category, dict):
category_name = category.get('name')
else:
category_name = category
category_dict = dict(name=category_name)
else:
category_dict = {}
data['category'] = category_dict
return data
代码声明了以下两个模式,具体来说,是ma.Schema类的两个子类:
-
CategorySchema -
MessageSchema
我们不使用 Flask-Marshmallow 提供的特性,这些特性允许我们根据模型中声明的字段自动确定每个属性的适当类型,因为我们想为每个字段使用特定的选项。我们将表示字段的属性声明为marshmallow.fields模块中声明的适当类的实例。当我们指定dump_only参数的True值时,这意味着我们希望该字段为只读。例如,我们无法为任何模式中的id字段提供值。该字段的值将由数据库中的自增主键自动生成。
CategorySchema类将name属性声明为fields.String的实例。将required参数设置为True以指定该字段不能为空字符串。将validate参数设置为validate.Length(3)以指定该字段必须至少有 3 个字符长。
该类通过以下行声明了url字段:
url = ma.URLFor('api.categoryresource', id='<id>', _external=True)
url属性是ma.URLFor类的实例,该字段将输出资源的完整 URL,即消息类别资源的 URL。第一个参数是 Flask 端点名称-'api.categoryresource'。我们将在稍后创建CategoryResource类,URLFor类将使用它来生成 URL。id参数指定'<id>',因为我们想从要序列化的对象中提取id。小于(<)和大于(>)符号内的id字符串指定我们希望从必须序列化的对象中提取字段。_external属性设置为True,因为我们想为资源生成完整的 URL。这样,每次我们序列化Category时,它都会在url键中包含资源的完整 URL。
小贴士
在这种情况下,我们正在使用不安全的 API 通过 HTTP。如果我们的 API 配置为 HTTPS,那么在创建ma.URLFor实例时,我们应该将_scheme参数设置为'https'。
该类通过以下行声明了messages字段:
messages = fields.Nested('MessageSchema', many=True, exclude=('category',)0029
messages 属性是 marshmallow.fields.Nested 类的实例,并且这个字段将嵌套一个 Schema 集合,因此我们为 many 参数指定了 True。第一个参数指定了嵌套 Schema 类的名称,作为一个字符串。我们在定义了 CategorySchema 类之后声明了 MessageSchema 类。因此,我们指定 Schema 类的名称为一个字符串,而不是使用我们尚未定义的类型。
事实上,我们将最终得到两个相互嵌套的对象,也就是说,我们将在类别和消息之间创建双向嵌套。我们使用一个字符串元组作为 exclude 参数,以指示我们希望从为每个消息序列化的字段中排除 category 字段。这样,我们可以避免无限递归,因为包含类别字段将序列化与该类别相关的所有消息。
当我们声明 Message 模型时,我们使用了 db.relationship 函数来提供与 Category 模型的多对一关系。backref 参数指定了一个调用 db.backref 函数的调用,其中 'messages' 作为第一个值,表示从相关的 Category 对象返回到 Message 对象的关系名称。通过之前解释的行,我们创建了使用我们为 db.backref 函数指定的相同名称的消息字段。
MessageSchema 类将 message 属性声明为 fields.String 类的实例。required 参数设置为 True,以指定该字段不能为空字符串。validate 参数设置为 validate.Length(1),以指定该字段必须至少有 1 个字符长。该类使用与我们在 Message 模型中使用的类型相对应的类声明了 duration、creation_date、printed_times 和 printed_once 字段。
类使用以下行声明了 category 字段:
category = fields.Nested(CategorySchema, only=['id', 'url', 'name'], required=True)
category 属性是 marshmallow.fields.Nested 类的实例,并且这个字段将嵌套一个 CategorySchema。我们为 required 参数指定了 True,因为消息必须属于一个类别。第一个参数指定了嵌套 Schema 类的名称。我们已经声明了 CategorySchema 类,因此我们指定 CategorySchema 作为第一个参数的值。我们使用一个包含字符串列表的唯一参数来指示在序列化嵌套 CategorySchema 时要包含的字段名称。我们希望包含 id、url 和 name 字段。我们没有指定 messages 字段,因为我们不希望类别序列化属于它的消息列表。
类使用以下行声明了 url 字段:
url = ma.URLFor('api.messageresource', id='<id>', _external=True)
url属性是ma.URLFor类的一个实例,这个字段将输出资源的完整 URL,即消息资源的 URL。第一个参数是 Flask 端点名称:'api.messageresource'。我们将在稍后创建MessageResource类,URLFor类将使用它来生成 URL。id参数指定为'<id>',因为我们希望从要序列化的对象中提取id。_external属性设置为True,因为我们希望为资源生成完整的 URL。这样,每次我们序列化一个Message时,它将在url键中包含资源的完整 URL。
MessageSchema类声明了一个使用@pre_load装饰器的方法,具体来说,是marshmallow.pre_load。这个装饰器注册了一个在反序列化对象之前调用的方法。这样,在 Marshmallow 反序列化消息之前,process_category方法将被执行。
该方法接收在data参数中的要反序列化的数据,并返回处理后的数据。当我们收到一个POST新消息的请求时,可以在名为'category'的键中指定类别名称。如果存在具有指定名称的类别,我们将使用现有的类别作为与新的消息相关联的类别。如果不存在具有指定名称的类别,我们将创建一个新的类别,然后我们将使用这个新类别作为与新的消息相关联的类别。这样,我们使得用户创建新消息变得容易。
data参数可能指定了作为'category'键的字符串形式的类别名称。然而,在其他情况下,'category'键将包括具有字段名称和字段值的现有类别的键值对。process_category方法中的代码检查'category'键的值,并返回一个包含适当数据的字典,以确保我们能够使用适当的键值对反序列化类别,无论传入数据的不同。最后,这些方法返回处理后的字典。我们将在开始编写和发送 HTTP 请求到 API 时,深入探讨process_category方法所做的工作。
将蓝图与资源丰富的路由相结合
现在,我们将创建组成我们的 RESTful API 主要构建块的资源。首先,我们将创建一些实例,我们将在不同的资源中使用它们。然后,我们将创建一个MessageResource类,我们将使用它来表示消息资源。在api文件夹内创建一个新的views.py文件,并添加以下行。示例的代码文件包含在restful_python_chapter_06_01文件夹中,如下所示:
from flask import Blueprint, request, jsonify, make_response
from flask_restful import Api, Resource
from models import db, Category, CategorySchema, Message, MessageSchema
from sqlalchemy.exc import SQLAlchemyError
import status
api_bp = Blueprint('api', __name__)
category_schema = CategorySchema()
message_schema = MessageSchema()
api = Api(api_bp)
class MessageResource(Resource):
def get(self, id):
message = Message.query.get_or_404(id)
result = message_schema.dump(message).data
return result
def patch(self, id):
message = Message.query.get_or_404(id)
message_dict = request.get_json(force=True)
if 'message' in message_dict:
message.message = message_dict['message']
if 'duration' in message_dict:
message.duration = message_dict['duration']
if 'printed_times' in message_dict:
message.printed_times = message_dict['printed_times']
if 'printed_once' in message_dict:
message.printed_once = message_dict['printed_once']
dumped_message, dump_errors = message_schema.dump(message)
if dump_errors:
return dump_errors, status.HTTP_400_BAD_REQUEST
validate_errors = message_schema.validate(dumped_message)
#errors = message_schema.validate(data)
if validate_errors:
return validate_errors, status.HTTP_400_BAD_REQUEST
try:
message.update()
return self.get(id)
except SQLAlchemyError as e:
db.session.rollback()
resp = jsonify({"error": str(e)})
return resp, status.HTTP_400_BAD_REQUEST
def delete(self, id):
message = Message.query.get_or_404(id)
try:
delete = message.delete(message)
response = make_response()
return response, status.HTTP_204_NO_CONTENT
except SQLAlchemyError as e:
db.session.rollback()
resp = jsonify({"error": str(e)})
return resp, status.HTTP_401_UNAUTHORIZED
前几行声明了导入并创建了以下实例,我们将在不同的类中使用它们:
-
api_bp: 它是flask.Blueprint类的一个实例,这将使我们能够将 Flask 应用程序分解为这个蓝图。第一个参数指定了我们想要注册蓝图的 URL 前缀:'api'。 -
category_schema: 它是我们在models.py模块中声明的CategorySchema类的一个实例。我们将使用category_schema来验证、序列化和反序列化类别。 -
message_schema: 它是我们在models.py模块中声明的MessageSchema类的一个实例。我们将使用message_schema来验证、序列化和反序列化类别。 -
api: 它是flask_restful.Api类的一个实例,代表应用程序的主要入口点。我们将之前创建的名为api_bp的flask.Blueprint实例作为参数传递,以将Api链接到Blueprint。
MessageResource类是flask_restful.Resource的子类,并声明了以下三个方法,当 HTTP 方法以相同的名称作为对表示资源的请求到达时将被调用:
-
get: 这个方法接收要检索的消息的 id,作为id参数。代码调用Message.query.get_or_404方法,在底层数据库中没有请求 id 的消息时返回 HTTP404 Not Found状态。如果消息存在,代码将调用message_schema.dump方法,将检索到的消息作为参数,使用MessageSchema实例序列化与指定id匹配的Message实例。dump方法接收Message实例,并应用在MessageSchema类中指定的字段过滤和输出格式化。代码返回dump方法返回的结果的data属性,即序列化的消息以 JSON 格式作为正文,带有默认的 HTTP200 OK状态码。 -
delete: 这个方法接收要删除的消息的 id,作为id参数。代码调用Message.query.get_or_404方法,在底层数据库中没有请求 id 的消息时返回 HTTP404 Not Found状态。如果消息存在,代码将调用message.delete方法,将检索到的消息作为参数,使用Message实例从数据库中删除自身。然后,代码返回一个空的响应体和一个204 No Content状态码。 -
patch: 此方法接收要更新或修补的消息的 id,作为id参数。代码调用Message.query.get_or_404方法,在底层数据库中没有请求 id 的消息时返回 HTTP404 Not Found状态。如果消息存在,代码调用request.get_json方法来检索请求中作为参数接收到的键值对。代码在Message实例的message_dict字典中更新特定属性,如果它们有新值:message。然后,代码调用message_schema.dump方法来检索序列化更新消息时生成的任何错误。如果有错误,代码返回错误和 HTTP400 Bad Request状态。如果没有生成错误,代码调用message_schema.validate方法来检索在验证更新消息时生成的任何错误。如果有验证错误,代码返回验证错误和 HTTP400 Bad Request状态。如果验证成功,代码调用 Message 实例的更新方法以在数据库中持久化更改,并返回调用之前解释的self.get方法的结果,其中将更新消息的 id 作为参数。这样,该方法以 JSON 格式作为主体返回序列化的更新消息,并带有默认的 HTTP200 OK状态码。
现在,我们将创建一个MessageListResource类,我们将使用它来表示消息集合。打开之前创建的api/views.py文件,并添加以下行。示例的代码文件包含在restful_python_chapter_06_01文件夹中:
class MessageListResource(Resource):
def get(self):
messages = Message.query.all()
result = message_schema.dump(messages, many=True).data
return result
def post(self):
request_dict = request.get_json()
if not request_dict:
response = {'message': 'No input data provided'}
return response, status.HTTP_400_BAD_REQUEST
errors = message_schema.validate(request_dict)
if errors:
return errors, status.HTTP_400_BAD_REQUEST
try:
category_name = request_dict['category']['name']
category = Category.query.filter_by(name=category_name).first()
if category is None:
# Create a new Category
category = Category(name=category_name)
db.session.add(category)
# Now that we are sure we have a category
# create a new Message
message = Message(
message=request_dict['message'],
duration=request_dict['duration'],
category=category)
message.add(message)
query = Message.query.get(message.id)
result = message_schema.dump(query).data
return result, status.HTTP_201_CREATED
except SQLAlchemyError as e:
db.session.rollback()
resp = jsonify({"error": str(e)})
return resp, status.HTTP_400_BAD_REQUEST
MessageListResource类是flask_restful.Resource的子类,并声明了以下两个方法,当在表示的资源上接收到具有相同名称的HTTP方法请求时将被调用:
-
get: 此方法返回一个包含数据库中保存的所有Message实例的列表。首先,代码调用Message.query.all方法来检索数据库中持久化的所有Message实例。然后,代码调用message_schema.dump方法,将检索到的消息和many参数设置为True以序列化对象的可迭代集合。dump方法将取自数据库检索到的每个Message实例,并应用由MessageSchema类指定的字段过滤和输出格式。代码返回由dump方法返回的结果的data属性,即以默认 HTTP200 OK状态码作为主体的 JSON 格式的序列化消息。 -
post: 此方法检索 JSON 体中接收到的键值对,创建一个新的Message实例并将其持久化到数据库中。如果指定的类别名称存在,则使用现有的类别。否则,该方法创建一个新的Category实例并将新消息关联到这个新类别。首先,代码调用request.get_json方法来检索请求中作为参数接收的键值对。然后,代码调用message_schema.validate方法来验证使用检索到的键值对构建的新消息。请记住,在调用验证方法之前,MessageSchema类将执行之前解释的process_category方法,因此数据将在验证之前进行处理。如果存在验证错误,代码将返回验证错误和 HTTP400 Bad Request状态。如果验证成功,代码将检索 JSON 体中接收到的类别名称,具体是在'category'键的'name'键的值中。然后,代码调用Category.query.filter_by方法来检索与检索到的类别名称匹配的类别。如果没有找到匹配项,代码将使用检索到的名称创建一个新的Category并将其持久化到数据库中。然后,代码创建一个新的消息,包含message、duration和适当的Category实例,并将其持久化到数据库中。最后,代码以 JSON 格式返回序列化的已保存消息作为正文,并带有 HTTP201 Created状态码。
现在,我们将创建一个CategoryResource类,我们将使用它来表示类别资源。打开之前创建的api/views.py文件,并添加以下行。示例的代码文件包含在restful_python_chapter_06_01文件夹中:
class CategoryResource(Resource):
def get(self, id):
category = Category.query.get_or_404(id)
result = category_schema.dump(category).data
return result
def patch(self, id):
category = Category.query.get_or_404(id)
category_dict = request.get_json()
if not category_dict:
resp = {'message': 'No input data provided'}
return resp, status.HTTP_400_BAD_REQUEST
errors = category_schema.validate(category_dict)
if errors:
return errors, status.HTTP_400_BAD_REQUEST
try:
if 'name' in category_dict:
category.name = category_dict['name']
category.update()
return self.get(id)
except SQLAlchemyError as e:
db.session.rollback()
resp = jsonify({"error": str(e)})
return resp, status.HTTP_400_BAD_REQUEST
def delete(self, id):
category = Category.query.get_or_404(id)
try:
category.delete(category)
response = make_response()
return response, status.HTTP_204_NO_CONTENT
except SQLAlchemyError as e:
db.session.rollback()
resp = jsonify({"error": str(e)})
return resp, status.HTTP_401_UNAUTHORIZED
CategoryResource类是flask_restful.Resource的子类,并声明了以下三个方法,当在表示的资源上接收到具有相同名称的 HTTP 方法请求时将被调用:
-
get: 此方法接收要检索的类别的 id 作为id参数。如果底层数据库中没有与请求的 id 匹配的类别,代码将调用Category.query.get_or_404方法返回 HTTP404 Not Found状态。如果消息存在,代码将调用category_schema.dump方法,将检索到的类别作为参数,使用CategorySchema实例来序列化与指定的id匹配的Category实例。dump方法接受Category实例并应用在CategorySchema类中指定的字段过滤和输出格式。代码返回dump方法返回的结果的data属性,即作为正文的序列化消息,以 JSON 格式,并带有默认的 HTTP200 OK状态码。 -
patch:此方法接收要更新或修补的类别的 id,作为id参数。代码调用Category.query.get_or_404方法,在底层数据库中没有请求 id 的类别时返回 HTTP404 Not Found状态。如果类别存在,代码调用request.get_json方法检索请求中作为参数接收的键值对。如果category_dict字典中的Category实例category有新值,则仅更新名称属性。然后,代码调用category_schema.validate方法检索在验证更新类别时生成的任何错误。如果有验证错误,代码返回验证错误和 HTTP400 Bad Request状态。如果验证成功,代码调用Category实例的更新方法以将更改持久化到数据库,并返回调用之前解释的self.get方法的结果,其中以更新类别的 id 作为参数。这样,该方法以 JSON 格式序列化的更新消息作为正文返回,默认 HTTP200 OK状态码。 -
delete:此方法接收要删除的类别的 id,作为id参数。代码调用Category.query.get_or_404方法,在底层数据库中没有请求 id 的类别时返回 HTTP404 Not Found状态。如果类别存在,代码调用category.delete方法,将检索到的类别作为参数,使用Category实例将其从数据库中删除。然后,代码返回一个空的响应体和一个204 No Content状态码。
现在,我们将创建一个 CategoryListResource 类,我们将使用它来表示类别集合。打开之前创建的 api/views.py 文件,并添加以下行。示例的代码文件包含在 restful_python_chapter_06_01 文件夹中:
class CategoryListResource(Resource):
def get(self):
categories = Category.query.all()
results = category_schema.dump(categories, many=True).data
return results
def post(self):
request_dict = request.get_json()
if not request_dict:
resp = {'message': 'No input data provided'}
return resp, status.HTTP_400_BAD_REQUEST
errors = category_schema.validate(request_dict)
if errors:
return errors, status.HTTP_400_BAD_REQUEST
try:
category = Category(request_dict['name'])
category.add(category)
query = Category.query.get(category.id)
result = category_schema.dump(query).data
return result, status.HTTP_201_CREATED
except SQLAlchemyError as e:
db.session.rollback()
resp = jsonify({"error": str(e)})
return resp, status.HTTP_400_BAD_REQUEST
CategoryListResource 类是 flask_restful.Resource 的子类,并声明了以下两个方法,当在表示的资源上接收到具有相同名称的 HTTP 方法请求时,将调用这些方法:
-
get:此方法返回一个包含在数据库中保存的所有Category实例的列表。首先,代码调用Category.query.all方法检索数据库中持久化的所有Category实例。然后,代码调用category_schema.dump方法,将检索到的消息和many参数设置为True以序列化对象的可迭代集合。dump方法将取从数据库检索到的每个Category实例,并应用由CategorySchema类指定的字段过滤和输出格式化。代码返回由dump方法返回的结果的data属性,即以 JSON 格式序列化的消息作为正文,默认 HTTP200 OK状态码。 -
post:此方法检索 JSON 主体中接收到的键值对,创建一个新的Category实例并将其持久化到数据库中。首先,代码调用request.get_json方法来检索作为请求参数接收到的键值对。然后,代码调用category_schema.validate方法来验证使用检索到的键值对构建的新类别。如果存在验证错误,代码将返回验证错误和 HTTP400 请求错误状态。如果验证成功,代码将创建一个新的类别,并使用指定的name持久化它。最后,代码以 JSON 格式返回序列化的已保存类别作为主体,并带有 HTTP201 已创建状态码。
以下表格显示了我们要为每个 HTTP 动词和作用域组合执行的先前创建的类的函数:
| HTTP 动词 | 作用域 | 类和方法 |
|---|---|---|
GET |
消息集合 | MessageListResource.get |
GET |
消息 | MessageResource.get |
POST |
消息集合 | MessageListResource.post |
PATCH |
消息 | MessageResource.patch |
DELETE |
消息 | MessageResource.delete |
GET |
分类集合 | CategoryListResource.get |
GET |
消息 | CategoryResource.get |
POST |
消息集合 | CategoryListResource.post |
PATCH |
消息 | CategoryResource.patch |
DELETE |
消息 | CategoryResource.delete |
如果请求导致调用一个不支持 HTTP 方法的资源,Flask-RESTful 将返回一个包含 HTTP 405 方法不允许 状态码的响应。
我们必须通过定义 URL 规则来进行必要的资源路由配置,以调用适当的方法,并通过传递所有必要的参数。以下行配置了 api 的资源路由。打开之前创建的 api/views.py 文件,并添加以下行。示例的代码文件包含在 restful_python_chapter_06_01 文件夹中:
api.add_resource(CategoryListResource, '/categories/')
api.add_resource(CategoryResource, '/categories/<int:id>')
api.add_resource(MessageListResource, '/messages/')
api.add_resource(MessageResource, '/messages/<int:id>')
每次调用 api.add_resource 方法都会将一个 URL 路由到一个资源,具体是到 flask_restful.Resource 类之前声明的子类之一。当有 API 请求且 URL 与 api.add_resource 方法中指定的 URL 匹配时,Flask 将调用与请求中 HTTP 动词匹配的指定类的相应方法。
注册蓝图和运行迁移
在 api 文件夹中创建一个新的 app.py 文件。以下行显示了创建 Flask 应用的代码。示例的代码文件包含在 restful_python_chapter_06_01 文件夹中。
from flask import Flask
def create_app(config_filename):
app = Flask(__name__)
app.config.from_object(config_filename)
from models import db
db.init_app(app)
from views import api_bp
app.register_blueprint(api_bp, url_prefix='/api')
return app
api/app.py文件中的代码声明了一个create_app函数,该函数接收配置文件名作为config_filename参数,使用此配置文件设置一个Flask应用程序,并返回app对象。首先,该函数创建 Flask 应用程序的主要入口点,命名为app。然后,代码调用app.config.from_object方法,将接收到的config_filename作为参数。这样,Flask 应用程序使用作为参数接收到的 Python 模块中定义的变量指定的值来设置Flask应用程序的设置。
下一行调用models模块中创建的flask_sqlalchemy.SQLAlchemy实例db的init_app方法。代码将app作为参数传递,以将创建的 Flask 应用程序与 SQLAlchemy 实例链接起来。
下一行调用app.register_blueprint方法来注册在views模块中创建的蓝图,命名为api_bp。url_prefix参数设置为'/api',因为我们希望资源以/api作为前缀可用。现在http://localhost:5000/api/将是运行在 Flask 开发服务器上的 API 的 URL。最后,函数返回app对象。
在api文件夹内创建一个新的run.py文件。以下行展示了使用之前定义的create_app函数创建 Flask 应用程序并运行的相关代码。示例代码文件包含在restful_python_chapter_06_01文件夹中。
from app import create_app
app = create_app('config')
if __name__ == '__main__':
app.run(host=app.config['HOST'],
port=app.config['PORT'],
debug=app.config['DEBUG'])
api/run.py文件中的代码调用在app模块中声明的create_app函数,参数为'config'。该函数将以该模块作为配置文件设置一个Flask应用程序。
最后一行只是调用app.run方法,以从config模块读取的主机、端口和调试值启动 Flask 应用程序。代码通过调用run方法立即启动本地服务器。记住,我们也可以使用flask命令行脚本来达到相同的目的。
在api文件夹内创建一个新的migrate.py文件。以下行展示了使用flask_script和flask_migrate运行迁移的代码。示例代码文件包含在restful_python_chapter_06_01文件夹中:
from flask_script import Manager
from flask_migrate import Migrate, MigrateCommand
from models import db
from run import app
migrate = Migrate(app, db)
manager = Manager(app)
manager.add_command('db', MigrateCommand)
if __name__ == '__main__':
manager.run()
代码创建了一个flask_migrate.Migrate实例,该实例使用之前在run模块中解释的app模块创建的Flask应用程序和models模块中创建的flask_sqlalchemy.SQLAlchemy实例db。然后,代码创建了一个flask_script.Manager类,将 Flask 应用程序作为参数传递,并将它的引用保存在manager变量中。下一行调用add_command方法,参数为'db'和MigrateCommand。主函数调用Manager实例的run方法。
这样,在扩展初始化后,代码向命令行选项中添加了一个 db 组。该 db 组有许多子命令,我们将通过migrate.py脚本使用这些子命令。
现在,我们将运行脚本以在 PostgreSQL 数据库中运行迁移并生成必要的表。请确保您在激活了虚拟环境的终端或命令提示符窗口中运行脚本,并且您位于api文件夹中。
运行第一个脚本,该脚本初始化应用程序的迁移支持。
python migrate.py db init
以下行显示了运行之前的脚本后生成的示例输出。您的输出将根据您创建虚拟环境的基准文件夹而有所不同:
Creating directory /Users/gaston/PythonREST/Flask02/api/migrations ... done
Creating directory /Users/gaston/PythonREST/Flask02/api/migrations/versions ... done
Generating /Users/gaston/PythonREST/Flask02/api/migrations/alembic.ini ... done
Generating /Users/gaston/PythonREST/Flask02/api/migrations/env.py ... done
Generating /Users/gaston/PythonREST/Flask02/api/migrations/README ... done
Generating /Users/gaston/PythonREST/Flask02/api/migrations/script.py.mako ... done
Please edit configuration/connection/logging settings in
'/Users/gaston/PythonREST/Flask02/api/migrations/alembic.ini' before proceeding.
脚本在api文件夹内生成了一个名为migrations的子文件夹,其中包含一个名为versions的子文件夹和许多其他文件。
运行第二个脚本,该脚本将检测到的模型更改填充到迁移脚本中。在这种情况下,这是我们第一次填充迁移脚本,因此迁移脚本将生成将持久化我们的两个模型(Category和Message)的表:
python migrate.py db migrate
以下行显示了运行之前的脚本后生成的示例输出。您的输出将根据您创建虚拟环境的基准文件夹而有所不同:
INFO [alembic.runtime.migration] Context impl PostgresqlImpl.
INFO [alembic.runtime.migration] Will assume transactional DDL.
INFO [alembic.autogenerate.compare] Detected added table 'category'
INFO [alembic.autogenerate.compare] Detected added table 'message'
Generating /Users/gaston/PythonREST/Flask02/api/migrations/versions/417543056ac3_.py ... done
输出指示api/migrations/versions/417543056ac3_.py文件包含了创建category和message表的代码。以下行显示了基于模型自动生成的此文件的代码。请注意,在您的配置中文件名将会不同。示例的代码文件包含在restful_python_chapter_06_01文件夹中:
"""empty message
Revision ID: 417543056ac3
Revises: None
Create Date: 2016-08-08 01:05:31.134631
"""
# revision identifiers, used by Alembic.
revision = '417543056ac3'
down_revision = None
from alembic import op
import sqlalchemy as sa
def upgrade():
### commands auto generated by Alembic - please adjust! ###
op.create_table('category',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=150), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('name')
)
op.create_table('message',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('message', sa.String(length=250), nullable=False),
sa.Column('duration', sa.Integer(), nullable=False),
sa.Column('creation_date', sa.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),
sa.Column('category_id', sa.Integer(), nullable=False),
sa.Column('printed_times', sa.Integer(), server_default='0', nullable=False),
sa.Column('printed_once', sa.Boolean(), server_default='false', nullable=False),
sa.ForeignKeyConstraint(['category_id'], ['category.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('message')
)
### end Alembic commands ###
def downgrade():
### commands auto generated by Alembic - please adjust! ###
op.drop_table('message')
op.drop_table('category')
### end Alembic commands ###
代码定义了两个函数:upgrade和downgrade。upgrade函数通过调用alembic.op.create_table来运行创建category和message表的必要代码。downgrade函数运行必要的代码以回到上一个版本。
运行第三个脚本以升级数据库:
python migrate.py db upgrade
以下行显示了运行之前的脚本后生成的示例输出:
INFO [alembic.runtime.migration] Context impl PostgresqlImpl.
INFO [alembic.runtime.migration] Will assume transactional DDL.
INFO [alembic.runtime.migration] Running upgrade -> 417543056ac3, empty message
之前的脚本调用了在自动生成的api/migrations/versions/417543056ac3_.py脚本中定义的upgrade函数。别忘了在您的配置中文件名将会不同。
在我们运行之前的脚本后,我们可以使用 PostgreSQL 命令行或任何允许我们轻松验证 PostreSQL 数据库内容的其他应用程序来检查迁移生成的表。
运行以下命令以列出生成的表。如果您使用的数据库名称不是messages,请确保您使用适当的数据库名称。
psql --username=user_name --dbname=messages --command="\dt"
以下行显示了所有生成的表名的输出:
List of relations
Schema | Name | Type | Owner
--------+-----------------+-------+-----------
public | alembic_version | table | user_name
public | category | table | user_name
public | message | table | user_name
(3 rows)
SQLAlchemy 根据我们模型中包含的信息生成了表、唯一约束和外键。
-
category:持久化Category模型。 -
message:持久化Message模型。
以下命令将允许您在我们编写并发送 HTTP 请求到 RESTful API 并对两个表执行 CRUD 操作后检查四个表的内容。这些命令假设您在运行命令的同一台计算机上运行 PostgreSQL:
psql --username=user_name --dbname=messages --command="SELECT * FROM category;"
psql --username=user_name --dbname=messages --command="SELECT * FROM message;"
提示
您可以使用 GUI 工具而不是 PostgreSQL 命令行实用程序来检查 PostgreSQL 数据库的内容。您还可以使用您最喜欢的 IDE 中包含的数据库工具来检查 SQLite 数据库的内容。
Alembic 生成了一个名为 alembic_version 的附加表,该表在 version_num 列中保存数据库的版本号。这个表使得迁移脚本能够检索数据库的当前版本,并根据我们的需求升级或降级它。
创建和检索相关资源
现在,我们可以运行api/run.py脚本以启动 Flask 的开发模式。在 api 文件夹中执行以下命令。
python run.py
以下行显示了执行上述命令后的输出。开发服务器正在端口 5000 上监听。
* Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)
* Restarting with stat
* Debugger is active!
* Debugger pin code: 198-040-402
现在,我们将使用 HTTPie 命令或其 curl 等效命令来编写并发送 HTTP 请求到 API。对于需要额外数据的请求,我们将使用 JSON。请记住,您可以使用您喜欢的基于 GUI 的工具执行相同的任务。
首先,我们将编写并发送 HTTP 请求来创建两个消息类别:
http POST :5000/api/categories/ name='Information'
http POST :5000/api/categories/ name='Warning'
以下是与 curl 命令等效的命令:
curl -iX POST -H "Content-Type: application/json" -d '{"name":"Information"}' :5000/api/categories/
curl -iX POST -H "Content-Type: application/json" -d '{"name":"Warning"}' :5000/api/categories/
上述命令将编写并发送两个带有指定 JSON 键值对的 POST HTTP 请求。请求指定 /api/categories/,因此它们将匹配 api_bp 蓝图的 '/api'url_prefix。然后,请求将匹配 '/categories/' URL 路由,并运行 CategoryList.post 方法。由于 URL 路由不包含任何参数,该方法不接收任何参数。由于请求的 HTTP 动词是 POST,Flask 调用 post 方法。如果两个新的 Category 实例成功持久化到数据库中,这两个调用将返回 HTTP 201 Created 状态码,并在响应体中将最近持久化的 Category 序列化为 JSON。以下行显示了两个 HTTP 请求的示例响应,其中包含 JSON 响应中的新 Category 对象。
注意,响应包括创建的类别的 URL,url。在两种情况下,messages数组都是空的,因为没有与每个新类别相关的消息:
HTTP/1.0 201 CREATED
Content-Length: 116
Content-Type: application/json
Date: Mon, 08 Aug 2016 05:26:58 GMT
Server: Werkzeug/0.11.10 Python/3.5.1
{
"id": 1,
"messages": [],
"name": "Information",
"url": "http://localhost:5000/api/categories/1"
}
HTTP/1.0 201 CREATED
Content-Length: 112
Content-Type: application/json
Date: Mon, 08 Aug 2016 05:27:05 GMT
Server: Werkzeug/0.11.10 Python/3.5.1
{
"id": 2,
"messages": [],
"name": "Warning",
"url": "http://localhost:5000/api/categories/2"
}
现在,我们将编写并发送 HTTP 请求来创建两个属于我们最近创建的第一个消息类别的消息:信息。我们将使用所需消息类别的名称指定category键。持久化Message模型的数据库表将保存与名称值匹配的关联Category的主键值:
http POST :5000/api/messages/ message='Checking temperature sensor' duration=5 category="Information"
http POST :5000/api/messages/ message='Checking light sensor' duration=8 category="Information"
以下是对应的 curl 命令:
curl -iX POST -H "Content-Type: application/json" -d '{"message":" Checking temperature sensor", "category":"Information"}' :5000/api/messages/
curl -iX POST -H "Content-Type: application/json" -d '{"message":" Checking light sensor", "category":"Information"}' :5000/api/messages/
第一个命令将编写并发送以下 HTTP 请求:POST http://localhost:5000/api/messages/,并带有以下 JSON 键值对:
{
"message": "Checking temperature sensor",
"category": "Information"
}
第二个命令将使用以下 JSON 键值对编写并发送相同的 HTTP 请求:
{
"message": "Checking light sensor",
"category": "Information"
}
请求指定了 /api/categories/,因此,它们将与 api_bp 蓝图的 '/api'url_prefix 匹配。然后,请求将与 MessageList 资源的 '/messages/' URL 路由匹配,并运行 MessageList.post 方法。由于 URL 路由不包含任何参数,该方法不接收任何参数。由于请求的 HTTP 动词是 POST,Flask 调用 post 方法。MessageSchema.process_category 方法将处理类别的数据,而 MessageListResource.post 方法将从数据库中检索与指定的类别名称匹配的 Category,用作新消息的相关类别。如果两个新的 Message 实例成功持久化到数据库中,这两个调用将返回 HTTP 201 Created 状态码,并在响应体中将最近持久化的 Message 序列化为 JSON。以下行显示了两个 HTTP 请求的示例响应,其中包含 JSON 响应中的新 Message 对象。请注意,响应包括创建消息的 URL,即 url。此外,响应还包括相关类别的 id、name 和 url。
HTTP/1.0 201 CREATED
Content-Length: 369
Content-Type: application/json
Date: Mon, 08 Aug 2016 15:18:43 GMT
Server: Werkzeug/0.11.10 Python/3.5.1
{
"category": {
"id": 1,
"name": "Information",
"url": "http://localhost:5000/api/categories/1"
},
"creation_date": "2016-08-08T12:18:43.260474+00:00",
"duration": 5,
"id": 1,
"message": "Checking temperature sensor",
"printed_once": false,
"printed_times": 0,
"url": "http://localhost:5000/api/messages/1"
}
HTTP/1.0 201 CREATED
Content-Length: 363
Content-Type: application/json
Date: Mon, 08 Aug 2016 15:27:30 GMT
Server: Werkzeug/0.11.10 Python/3.5.1
{
"category": {
"id": 1,
"name": "Information",
"url": "http://localhost:5000/api/categories/1"
},
"creation_date": "2016-08-08T12:27:30.124511+00:00",
"duration": 8,
"id": 2,
"message": "Checking light sensor",
"printed_once": false,
"printed_times": 0,
"url": "http://localhost:5000/api/messages/2"
}
我们可以运行前面的命令来检查在 PostgreSQL 数据库中创建的迁移的表内容。我们会注意到 message 表的 category_id 列保存了 category 表中相关行的主键值。MessageSchema 类使用 fields.Nested 实例来渲染相关 Category 的 id、url 和名称字段。以下截图显示了在运行 HTTP 请求后 PostgreSQL 数据库中 category 和 message 表的内容:

现在,我们将编写并发送一个 HTTP 请求来检索包含两个消息的类别,即 id 或主键等于 1 的类别资源。不要忘记将 1 替换为您配置中名称等于 'Information' 的类别的主键值:
http :5000/api/categories/1
以下是对应的 curl 命令:
curl -iX GET :5000/api/categories/1
前面的命令将组合并发送一个 GET HTTP 请求。请求在 /api/categories/ 后面有一个数字,因此,它将匹配 '/categories/<int:id>' 并运行 CategoryResource.get 方法,即 CategoryResource 类的 get 方法。如果数据库中存在具有指定 id 的 Category 实例,该方法调用将返回 HTTP200 OK 状态码,并在响应体中将 Category 实例序列化为 JSON。CategorySchema 类使用一个 fields.Nested 实例来渲染与类别相关的所有消息的字段,除了类别字段。以下行显示了一个示例响应:
HTTP/1.0 200 OK
Content-Length: 1078
Content-Type: application/json
Date: Mon, 08 Aug 2016 16:09:10 GMT
Server: Werkzeug/0.11.10 Python/3.5.1
{
"id": 1,
"messages": [
{
"category": {
"id": 1,
"name": "Information",
"url": "http://localhost:5000/api/categories/1"
},
"creation_date": "2016-08-08T12:27:30.124511+00:00",
"duration": 8,
"id": 2,
"message": "Checking light sensor",
"printed_once": false,
"printed_times": 0,
"url": "http://localhost:5000/api/messages/2"
},
{
"category": {
"id": 1,
"name": "Information",
"url": "http://localhost:5000/api/categories/1"
},
"creation_date": "2016-08-08T12:18:43.260474+00:00",
"duration": 5,
"id": 1,
"message": "Checking temperature sensor",
"printed_once": false,
"printed_times": 0,
"url": "http://localhost:5000/api/messages/1"
}
],
"name": "Information",
"url": "http://localhost:5000/api/categories/1"
}
现在,我们将组合并发送一个 POST HTTP 请求来创建一个与不存在类别名称相关的消息:'Error':
http POST :5000/api/messages/ message='Temperature sensor error' duration=10 category="Error"
以下是对应的 curl 命令:
curl -iX POST -H "Content-Type: application/json" -d '{"message":" Temperature sensor error", "category":"Error"}' :5000/api/messages/
CategoryListResource.post 方法无法检索一个 name 等于指定值的 Category 实例,因此,该方法将创建一个新的 Category,保存它并将其用作新消息的相关类别。以下行显示了一个 HTTP 请求的示例响应,其中包含 JSON 响应中的新 Message 对象以及与消息相关的新 Category 对象的详细信息:
HTTP/1.0 201 CREATED
Content-Length: 361
Content-Type: application/json
Date: Mon, 08 Aug 2016 17:20:22 GMT
Server: Werkzeug/0.11.10 Python/3.5.1
{
"category": {
"id": 3,
"name": "Error",
"url": "http://localhost:5000/api/categories/3"
},
"creation_date": "2016-08-08T14:20:22.103752+00:00",
"duration": 10,
"id": 3,
"message": "Temperature sensor error",
"printed_once": false,
"printed_times": 0,
"url": "http://localhost:5000/api/messages/3"
}
我们可以运行前面解释的命令来检查在 PostgreSQL 数据库中由迁移创建的表的内容。当我们创建新消息时,我们会注意到类别表中出现了一行新行,其中包含最近添加的类别。以下屏幕截图显示了在运行 HTTP 请求后 PostgreSQL 数据库中 category 和 message 表的内容:

测试你的知识
-
Marshmallow 是:
-
一个用于将复杂数据类型转换为和从原生 Python 数据类型转换的轻量级库。
-
一个 ORM。
-
一个轻量级 Web 框架,用于替代 Flask。
-
-
SQLAlchemy 是:
-
一个用于将复杂数据类型转换为和从原生 Python 数据类型转换的轻量级库。
-
一个 ORM。
-
一个轻量级 Web 框架,用于替代 Flask。
-
-
marshmallow.pre_load装饰器:-
在创建
MessageSchema类的任何实例之后注册一个要运行的方法。 -
在序列化对象之后注册一个要调用的方法。
-
在反序列化对象之前注册一个要调用的方法。
-
-
任何 Schema 子类实例的
dump方法:-
将 URL 路由到 Python 原语。
-
将作为参数传递的实例或实例集合持久化到数据库。
-
接受作为参数传递的实例或实例集合,并将 Schema 子类中指定的字段过滤和输出格式应用于实例或实例集合。
-
-
当我们声明一个属性为
marshmallow.fields.Nested类的实例时:-
该字段将根据
many参数的值嵌套单个Schema或Schema的集合。 -
该字段将嵌套一个单个
Schema。如果我们想嵌套一个Schema集合,我们必须使用marshmallow.fields.NestedCollection类的实例。 -
该字段将嵌套一个
Schema集合。如果我们想嵌套一个单个Schema,我们必须使用marshmallow.fields.NestedSingle类的实例。
-
摘要
在本章中,我们扩展了上一章中创建的 RESTful API 的功能。我们使用 SQLAlchemy 作为我们的 ORM 与 PostgreSQL 数据库一起工作。我们安装了许多包以简化许多常见任务,为模型及其关系编写了代码,并使用模式来验证、序列化和反序列化这些模型。
我们将蓝图与资源路由相结合,能够从模型生成数据库。我们对 RESTful API 发送了许多 HTTP 请求,并分析了每个 HTTP 请求在我们的代码中是如何处理的,以及模型如何在数据库表中持久化。
现在我们已经使用 Flask、Flask-RESTful 和 SQLAlchemy 构建了一个复杂的 API,我们将使用额外的功能并添加安全性和认证,这是我们将在下一章中讨论的内容。
第七章. 使用 Flask 改进和添加认证到 API
在本章中,我们将改进上一章中开始构建的 RESTful API,并将与之相关的认证安全添加到其中。我们将:
-
改进模型中的唯一约束
-
使用
PATCH方法更新资源的字段 -
编写一个通用的分页类
-
为 API 添加分页功能
-
理解添加认证和权限的步骤
-
添加用户模型
-
创建一个模式来验证、序列化和反序列化用户
-
为资源添加认证
-
创建用于处理用户的资源类
-
运行迁移以生成用户表
-
使用必要的认证组合请求
改进模型中的唯一约束
当我们创建Category模型时,我们在创建名为name的db.Column实例时指定了唯一参数的True值。因此,迁移生成了必要的唯一约束,以确保name字段在category表中具有唯一值。这样,数据库将不允许我们为category.name插入重复值。然而,当我们尝试这样做时生成的错误信息并不清晰。
运行以下命令以创建一个具有重复名称的类别。已经存在一个名为'Information'的现有类别:
http POST :5000/api/categories/ name='Information'
以下是对应的curl命令:
curl -iX POST -H "Content-Type: application/json" -d '{"name":"Information"}'
:5000/api/categories/
之前的命令将组合并发送一个带有指定 JSON 键值对的POST HTTP 请求。category.name字段中的唯一约束将不允许数据库表持久化新的类别。因此,请求将返回一个带有完整性错误信息的 HTTP 400 Bad Request状态码。以下行显示了示例响应:
HTTP/1.0 400 BAD REQUEST
Content-Length: 282
Content-Type: application/json
Date: Mon, 15 Aug 2016 03:53:27 GMT
Server: Werkzeug/0.11.10 Python/3.5.1
{
"error": "(psycopg2.IntegrityError) duplicate key value violates unique
constraint "category_name_key"\nDETAIL: Key (name)=(Information)
already exists.\n [SQL: 'INSERT INTO category (name) VALUES (%(name)s)
RETURNING category.id'] [parameters: {'name': 'Information'}]"
}
显然,错误信息非常技术化,提供了太多关于数据库和失败查询的细节。我们可能会解析错误信息以自动生成一个更用户友好的错误信息。然而,我们不想尝试插入一个我们知道会失败的行。我们将在尝试持久化之前添加代码以确保类别是唯一的。当然,仍然有可能在我们运行代码和将更改持久化到数据库之间,有人插入了一个具有相同名称的类别,这表明类别名称是唯一的。然而,这种情况的可能性较低,我们可以降低之前显示的错误信息被显示的可能性。
小贴士
在一个生产就绪的 REST API 中,我们永远不应该返回由 SQLAlchemy 或其他数据库相关数据返回的错误信息,因为这可能包括我们不希望 API 用户能够检索的敏感数据。在这种情况下,我们返回所有错误是为了调试目的,并能够改进我们的 API。
现在,我们将在 Category 类中添加一个新的类方法,以便我们能够确定名称是否唯一。打开 api/models.py 文件,在 Category 类的声明中添加以下行。示例代码文件包含在 restful_python_chapter_07_01 文件夹中:
@classmethod
def is_unique(cls, id, name):
existing_category = cls.query.filter_by(name=name).first()
if existing_category is None:
return True
else:
if existing_category.id == id:
return True
else:
return False
新的 Category.is_unique 类方法接收我们想要确保具有唯一名称的类别的 id 和 name。如果类别是新的且尚未保存,我们将收到 id 值为 0。否则,我们将收到作为参数传递的类别 id。
该方法调用当前类的 query.filter_by 方法来检索一个名称与另一个类别名称匹配的类别。如果存在匹配条件的类别,则方法将仅在 id 与参数中接收的 id 相同时返回 True。如果没有类别匹配条件,则方法将返回 True。
我们将在创建并持久化之前使用之前创建的类方法来检查类别是否唯一。打开 api/views.py 文件,并用以下行替换 CategoryListResource 类中声明的现有 post 方法。已添加或修改的行已突出显示。示例代码文件包含在 restful_python_chapter_07_01 文件夹中:
def post(self):
request_dict = request.get_json()
if not request_dict:
resp = {'message': 'No input data provided'}
return resp, status.HTTP_400_BAD_REQUEST
errors = category_schema.validate(request_dict)
if errors:
return errors, status.HTTP_400_BAD_REQUEST
category_name = request_dict['name']
if not Category.is_unique(id=0, name=category_name):
response = {'error': 'A category with the same name already exists'}
return response, status.HTTP_400_BAD_REQUEST
try:
category = Category(category_name)
category.add(category)
query = Category.query.get(category.id)
result = category_schema.dump(query).data
return result, status.HTTP_201_CREATED
except SQLAlchemyError as e:
db.session.rollback()
resp = {"error": str(e)}
return resp, status.HTTP_400_BAD_REQUEST
现在,我们将在 CategoryResource.patch 方法中执行相同的验证。打开 api/views.py 文件,并用以下行替换 CategoryResource 类中声明的现有 patch 方法。已添加或修改的行已突出显示。示例代码文件包含在 restful_python_chapter_07_01 文件夹中:
def patch(self, id):
category = Category.query.get_or_404(id)
category_dict = request.get_json()
if not category_dict:
resp = {'message': 'No input data provided'}
return resp, status.HTTP_400_BAD_REQUEST
errors = category_schema.validate(category_dict)
if errors:
return errors, status.HTTP_400_BAD_REQUEST
try:
if 'name' in category_dict:
category_name = category_dict['name']
if Category.is_unique(id=id, name=category_name):
category.name = category_name
else:
response = {'error': 'A category with the same name already
exists'}
return response, status.HTTP_400_BAD_REQUEST
category.update()
return self.get(id)
except SQLAlchemyError as e:
db.session.rollback()
resp = {"error": str(e)}
return resp, status.HTTP_400_BAD_REQUEST
运行以下命令再次创建一个具有重复名称的类别:
http POST :5000/api/categories/ name='Information'
以下是对应的 curl 命令:
curl -iX POST -H "Content-Type: application/json" -d '{"name":"Information"}'
:5000/api/categories/
之前的命令将组合并发送一个带有指定 JSON 键值对的 POST HTTP 请求。我们所做的更改将生成一个带有用户友好错误消息的响应,并避免尝试持久化更改。请求将返回一个带有 JSON 主体中错误消息的 HTTP 400 Bad Request 状态码。以下行显示了一个示例响应:
HTTP/1.0 400 BAD REQUEST
Content-Length: 64
Content-Type: application/json
Date: Mon, 15 Aug 2016 04:38:43 GMT
Server: Werkzeug/0.11.10 Python/3.5.1
{
"error": "A category with the same name already exists"
}
现在,我们将在 Message 类中添加一个新的 class 方法,以便我们能够确定消息是否唯一。打开 api/models.py 文件,在 Message 类的声明中添加以下行。示例代码文件包含在 restful_python_chapter_07_01 文件夹中:
@classmethod
def is_unique(cls, id, message):
existing_message = cls.query.filter_by(message=message).first()
if existing_message is None:
return True
else:
if existing_message.id == id:
return True
else:
return False
新的 Message.is_unique 类方法接收我们想要确保消息字段具有唯一值的消息的 id 和 message。如果消息是新的且尚未保存,我们将收到 id 值为 0。否则,我们将收到作为参数传递的消息 id。
该方法调用当前类的query.filter_by方法来检索一个消息字段与另一个消息的消息字段匹配的消息。如果存在符合条件的信息,则方法将仅在 id 与参数中接收到的 id 相同时返回True。如果没有消息符合条件,则方法将返回True。
我们将在创建并持久化到MessageListResource.post方法之前,使用之前创建的类方法来检查消息是否唯一。打开api/views.py文件,将MessageListResource类中声明的现有post方法替换为以下行。已添加或修改的行已突出显示。示例的代码文件包含在restful_python_chapter_07_01文件夹中:
def post(self):
request_dict = request.get_json()
if not request_dict:
response = {'message': 'No input data provided'}
return response, status.HTTP_400_BAD_REQUEST
errors = message_schema.validate(request_dict)
if errors:
return errors, status.HTTP_400_BAD_REQUEST
message_message = request_dict['message']
if not Message.is_unique(id=0, message=message_message):
response = {'error': 'A message with the same message already
exists'}
return response, status.HTTP_400_BAD_REQUEST
try:
category_name = request_dict['category']['name']
category = Category.query.filter_by(name=category_name).first()
if category is None:
# Create a new Category
category = Category(name=category_name)
db.session.add(category)
# Now that we are sure we have a category
# create a new Message
message = Message(
message=message_message,
duration=request_dict['duration'],
category=category)
message.add(message)
query = Message.query.get(message.id)
result = message_schema.dump(query).data
return result, status.HTTP_201_CREATED
except SQLAlchemyError as e:
db.session.rollback()
resp = {"error": str(e)}
return resp, status.HTTP_400_BAD_REQUEST
现在,我们将在MessageResource.patch方法中执行相同的验证。打开api/views.py文件,将MessageResource类中声明的现有patch方法替换为以下行。已添加或修改的行已突出显示。示例的代码文件包含在restful_python_chapter_07_01文件夹中:
def patch(self, id):
message = Message.query.get_or_404(id)
message_dict = request.get_json(force=True)
if 'message' in message_dict:
message_message = message_dict['message']
if Message.is_unique(id=id, message=message_message):
message.message = message_message
else:
response = {'error': 'A message with the same message already
exists'}
return response, status.HTTP_400_BAD_REQUEST
if 'duration' in message_dict:
message.duration = message_dict['duration']
if 'printed_times' in message_dict:
message.printed_times = message_dict['printed_times']
if 'printed_once' in message_dict:
message.printed_once = message_dict['printed_once']
dumped_message, dump_errors = message_schema.dump(message)
if dump_errors:
return dump_errors, status.HTTP_400_BAD_REQUEST
validate_errors = message_schema.validate(dumped_message)
if validate_errors:
return validate_errors, status.HTTP_400_BAD_REQUEST
try:
message.update()
return self.get(id)
except SQLAlchemyError as e:
db.session.rollback()
resp = {"error": str(e)}
return resp, status.HTTP_400_BAD_REQUEST
运行以下命令以创建一个消息字段值为重复的消息:
http POST :5000/api/messages/ message='Checking temperature sensor' duration=25 category="Information"
以下是对应的curl命令:
curl -iX POST -H "Content-Type: application/json" -d '{"message":"Checking temperature sensor", "duration":25, "category": "Information"}' :5000/api/messages/
之前的命令将组合并发送一个带有指定 JSON 键值对的POST HTTP 请求。我们所做的更改将生成一个带有用户友好错误消息的响应,并避免尝试在消息中持久化更改。请求将返回带有 JSON 正文中的错误消息的 HTTP 400 Bad Request状态码。以下行显示了示例响应:
HTTP/1.0 400 BAD REQUEST
Content-Length: 66
Content-Type: application/json
Date: Mon, 15 Aug 2016 04:55:46 GMT
Server: Werkzeug/0.11.10 Python/3.5.1
{
"error": "A message with the same message already exists"
}
使用PATCH方法更新资源的字段
正如我们在第六章中解释的,在 Flask 中使用模型、SQLAlchemy 和超链接 API,我们的 API 能够更新现有资源的单个字段,因此,我们提供了一个PATCH方法的实现。例如,我们可以使用PATCH方法来更新现有的消息,并将其printed_once和printed_times字段的值设置为true和1。我们不希望使用PUT方法,因为这个方法旨在替换整个消息。PATCH方法旨在将增量应用于现有消息,因此,它是仅更改这两个字段值的适当方法。
现在,我们将编写并发送一个 HTTP 请求来更新现有的消息,具体来说,是更新printed_once和printed_times字段的值。因为我们只想更新两个字段,所以我们将使用PATCH方法而不是PUT。确保将1替换为您配置中现有消息的 id 或主键:
http PATCH :5000/api/messages/1 printed_once=true printed_times=1
以下是对应的curl命令:
curl -iX PATCH -H "Content-Type: application/json" -d '{"printed_once":"true", "printed_times":1}' :5000/api/messages/1
之前的命令将编写并发送一个包含以下指定 JSON 键值对的 PATCH HTTP 请求:
{
"printed_once": true,
"printed_times": 1
}
请求在 /api/messages/ 后面有一个数字,因此,它将匹配 '/messages/<int:id>' 并运行 MessageResource.patch 方法,即 MessageResource 类的 patch 方法。如果存在具有指定 id 的 Message 实例,代码将检索请求字典中 printed_times 和 printed_once 键的值,更新 Message 实例并验证它。
如果更新的 Message 实例有效,代码将在数据库中持久化更改,并且对方法的调用将返回 HTTP 200 OK 状态码,以及最近更新的 Message 实例序列化为 JSON 的响应体。以下行显示了示例响应:
HTTP/1.0 200 OK
Content-Length: 368
Content-Type: application/json
Date: Tue, 09 Aug 2016 22:38:39 GMT
Server: Werkzeug/0.11.10 Python/3.5.1
{
"category": {
"id": 1,
"name": "Information",
"url": "http://localhost:5000/api/categories/1"
},
"creation_date": "2016-08-08T12:18:43.260474+00:00",
"duration": 5,
"id": 1,
"message": "Checking temperature sensor",
"printed_once": true,
"printed_times": 1,
"url": "http://localhost:5000/api/messages/1"
}
我们可以运行第六章“使用 Flask 中的模型、SQLAlchemy 和超链接 API”中解释的命令,“使用 Flask 中的模型、SQLAlchemy 和超链接 API”,以检查在 PostgreSQL 数据库中创建的迁移创建的表的内容。我们会注意到消息表中的 printed_times 和 printed_once 值已被更新。以下屏幕截图显示了在运行 HTTP 请求后 PostgreSQL 数据库中更新后的 message 表的行内容。屏幕截图显示了执行以下 SQL 查询的结果:SELECT * FROM message WHERE id = 1:

编写一个通用的分页类
我们的数据库为每个持久化我们定义的模型的表都保留了几行。然而,在我们开始在现实生活中的生产环境中使用我们的 API 之后,我们将有数百条消息,因此,我们将不得不处理大量结果集。因此,我们将创建一个通用的分页类,并使用它来轻松指定我们希望如何将大量结果集分割成单独的数据页。
首先,我们将编写并发送 HTTP 请求来创建属于我们创建的某个类别(Information)的 9 条消息。这样,我们将有总共 12 条消息持久化在数据库中。我们原本有 3 条消息,然后又增加了 9 条。
http POST :5000/api/messages/ message='Initializing light controller' duration=25 category="Information"
http POST :5000/api/messages/ message='Initializing light sensor' duration=20 category="Information"
http POST :5000/api/messages/ message='Checking pressure sensor' duration=18 category="Information"
http POST :5000/api/messages/ message='Checking gas sensor' duration=14 category="Information"
http POST :5000/api/messages/ message='Setting ADC resolution' duration=22 category="Information"
http POST :5000/api/messages/ message='Setting sample rate' duration=15 category="Information"
http POST :5000/api/messages/ message='Initializing pressure sensor' duration=18 category="Information"
http POST :5000/api/messages/ message='Initializing gas sensor' duration=16 category="Information"
http POST :5000/api/messages/ message='Initializing proximity sensor' duration=5 category="Information"
以下是对应的 curl 命令:
curl -iX POST -H "Content-Type: application/json" -d '{"message":" Initializing light controller", "duration":25, "category": "Information"}' :5000/api/messages/
curl -iX POST -H "Content-Type: application/json" -d '{"message":"Initializing light sensor", "duration":20, "category": "Information"}' :5000/api/messages/
curl -iX POST -H "Content-Type: application/json" -d '{"message":"Checking pressure sensor", "duration":18, "category": "Information"}' :5000/api/messages/
curl -iX POST -H "Content-Type: application/json" -d '{"message":"Checking gas sensor", "duration":14, "category": "Information"}' :5000/api/messages/
curl -iX POST -H "Content-Type: application/json" -d '{"message":"Setting ADC resolution", "duration":22, "category": "Information"}' :5000/api/messages/
curl -iX POST -H "Content-Type: application/json" -d '{"message":"Setting sample rate", "duration":15, "category": "Information"}' :5000/api/messages/
curl -iX POST -H "Content-Type: application/json" -d '{"message":"Initializing pressure sensor", "duration":18, "category": "Information"}' :5000/api/messages/
curl -iX POST -H "Content-Type: application/json" -d '{"message":"Initializing gas sensor", "duration":16, "category": "Information"}' :5000/api/messages/
curl -iX POST -H "Content-Type: application/json" -d '{"message":"Initializing proximity sensor", "duration":5, "category": "Information"}' :5000/api/messages/
之前的命令将编写并发送九个包含指定 JSON 键值对的 POST HTTP 请求。请求指定 /api/messages/,因此,它将匹配 '/messages/' 并运行 MessageListResource.post 方法,即 MessageListResource 类的 post 方法。
现在,我们数据库中有 12 条消息。然而,当我们编写并发送一个 GET HTTP 请求到 /api/messages/ 时,我们不想检索这 12 条消息。我们将创建一个可定制的通用分页类,以在每个单独的数据页中包含最多 5 个资源。
打开api/config.py文件,并添加以下行,声明两个变量以配置全局分页设置。示例代码文件包含在restful_python_chapter_07_01文件夹中:
PAGINATION_PAGE_SIZE = 5
PAGINATION_PAGE_ARGUMENT_NAME = 'page'
PAGINATION_PAGE_SIZE变量的值指定了一个全局设置,其默认值为页面大小,也称为限制。PAGINATION_PAGE_ARGUMENT_NAME变量的值指定了一个全局设置,其默认值为我们将用于请求中指定要检索的页码的参数名称。
在api文件夹中创建一个新的helpers.py文件。以下行显示了创建新的PaginationHelper类的代码。示例代码文件包含在restful_python_chapter_07_01文件夹中:
from flask import url_for
from flask import current_app
class PaginationHelper():
def __init__(self, request, query, resource_for_url, key_name, schema):
self.request = request
self.query = query
self.resource_for_url = resource_for_url
self.key_name = key_name
self.schema = schema
self.results_per_page = current_app.config['PAGINATION_PAGE_SIZE']
self.page_argument_name =
current_app.config['PAGINATION_PAGE_ARGUMENT_NAME']
def paginate_query(self):
# If no page number is specified, we assume the request wants page #1
page_number = self.request.args.get(self.page_argument_name, 1, type=int)
paginated_objects = self.query.paginate(
page_number,
per_page=self.results_per_page,
error_out=False)
objects = paginated_objects.items
if paginated_objects.has_prev:
previous_page_url = url_for(
self.resource_for_url,
page=page_number-1,
_external=True)
else:
previous_page_url = None
if paginated_objects.has_next:
next_page_url = url_for(
self.resource_for_url,
page=page_number+1,
_external=True)
else:
next_page_url = None
dumped_objects = self.schema.dump(objects, many=True).data
return ({
self.key_name: dumped_objects,
'previous': previous_page_url,
'next': next_page_url,
'count': paginated_objects.total
})
PaginationHelper类声明了一个构造函数,即__init__方法,它接收许多参数并使用它们来初始化具有相同名称的属性:
-
request:Flask 请求对象,它将允许paginate_query方法检索通过 HTTP 请求指定的页码值。 -
query:paginate_query方法必须分页的 SQLAlchemy 查询。 -
resource_for_url:一个字符串,包含paginate_query方法将用于生成上一页和下一页的完整 URL 的资源名称。 -
key_name:一个字符串,包含paginate_query方法将使用的关键名称来返回序列化的对象。 -
schema:paginate_query方法必须使用的 Flask-MarshmallowSchema子类,用于序列化对象。
此外,构造函数读取并保存了我们添加到config.py文件中的配置变量的值,并将其保存到results_per_page和page_argument_name属性中。
类声明了paginate_query方法。首先,代码检索请求中指定的页码并将其保存到page_number变量中。如果没有指定页码,代码假定请求需要第一页。然后,代码调用self.query.paginate方法,根据self.results_per_page属性的值检索数据库中对象的分页结果中指定的page_number页码,每页的结果数量由self.results_per_page属性的值指示。下一行将paginated_object.items属性中的分页项保存到objects变量中。
如果paginated_objects.has_prev属性的值为True,则表示有上一页可用。在这种情况下,代码调用flask.url_for函数,使用self.resource_for_url属性的值生成上一页的完整 URL。_external参数设置为True,因为我们想提供完整的 URL。
如果paginated_objects.has_next属性的值为True,则表示存在下一页。在这种情况下,代码会调用flask.url_for函数,生成包含self.resource_for_url属性值的下一页的完整 URL。
然后,代码调用self.schema.dump方法,将之前保存在object变量中的部分结果序列化,将many参数设置为True。dumped_objects变量保存了对dump方法返回的结果的data属性的引用。
最后,该方法返回一个包含以下键值对的字典:
-
self.key_name:保存于dumped_objects变量中的序列化部分结果。 -
'previous':保存于previous_page_url变量中的上一页的完整 URL。 -
'previous':保存于next_page_url变量中的下一页的完整 URL。 -
'count':从paginated_objects.total属性检索到的完整结果集中可用的对象总数。
添加分页功能
打开api/views.py文件,将MessageListResource.get方法的代码替换为下一列表中突出显示的行。此外,请确保添加导入语句。示例代码文件包含在restful_python_chapter_07_01文件夹中:
from helpers import PaginationHelper
class MessageListResource(Resource):
def get(self):
pagination_helper = PaginationHelper(
request,
query=Message.query,
resource_for_url='api.messagelistresource',
key_name='results',
schema=message_schema)
result = pagination_helper.paginate_query()
return result
get方法的新代码创建了一个名为pagination_helper的之前解释过的PaginationHelper类实例,将request对象作为第一个参数。命名参数指定了PaginationHelper实例必须使用的query、resource_for_url、key_name和schema,以提供分页查询结果。
下一行调用pagination_helper.paginate_query方法,该方法将返回指定请求中页码的分页查询结果。最后,该方法返回包含之前解释过的字典的调用结果。在这种情况下,包含消息的分页结果集将作为'results'键的值渲染,该键由key_name参数指定。
现在,我们将编写并发送一个 HTTP 请求来检索所有消息,具体是一个针对/api/messages/的 HTTP GET方法。
http :5000/api/messages/
以下是对应的 curl 命令:
curl -iX GET :5000/api/messages/
MessageListResource.get方法的新代码将支持分页,并提供的结果将包括前 5 条消息(results键)、查询的总消息数(count键)以及指向下一页(next键)和上一页(previous键)的链接。在这种情况下,结果集是第一页,因此,指向上一页的链接(previous键)是null。我们将在响应头中收到200 OK状态码,并在results数组中收到 5 条消息:
HTTP/1.0 200 OK
Content-Length: 2521
Content-Type: application/json
Date: Wed, 10 Aug 2016 18:26:44 GMT
Server: Werkzeug/0.11.10 Python/3.5.1
{
"count": 12,
"results": [
{
"category": {
"id": 1,
"name": "Information",
"url": "http://localhost:5000/api/categories/1"
},
"creation_date": "2016-08-08T12:27:30.124511+00:00",
"duration": 8,
"id": 2,
"message": "Checking light sensor",
"printed_once": false,
"printed_times": 0,
"url": "http://localhost:5000/api/messages/2"
},
{
"category": {
"id": 3,
"name": "Error",
"url": "http://localhost:5000/api/categories/3"
},
"creation_date": "2016-08-08T14:20:22.103752+00:00",
"duration": 10,
"id": 3,
"message": "Temperature sensor error",
"printed_once": false,
"printed_times": 0,
"url": "http://localhost:5000/api/messages/3"
},
{
"category": {
"id": 1,
"name": "Information",
"url": "http://localhost:5000/api/categories/1"
},
"creation_date": "2016-08-08T12:18:43.260474+00:00",
"duration": 5,
"id": 1,
"message": "Checking temperature sensor",
"printed_once": true,
"printed_times": 1,
"url": "http://localhost:5000/api/messages/1"
},
{
"category": {
"id": 1,
"name": "Information",
"url": "http://localhost:5000/api/categories/1"
},
"creation_date": "2016-08-09T20:18:26.648071+00:00",
"duration": 25,
"id": 4,
"message": "Initializing light controller",
"printed_once": false,
"printed_times": 0,
"url": "http://localhost:5000/api/messages/4"
},
{
"category": {
"id": 1,
"name": "Information",
"url": "http://localhost:5000/api/categories/1"
},
"creation_date": "2016-08-09T20:19:16.174807+00:00",
"duration": 20,
"id": 5,
"message": "Initializing light sensor",
"printed_once": false,
"printed_times": 0,
"url": "http://localhost:5000/api/messages/5"
}
],
"next": "http://localhost:5000/api/messages/?page=2",
"previous": null
}
在之前的 HTTP 请求中,我们没有指定page参数的任何值,因此PaginationHelper类中的paginate_query方法请求分页查询的第一页。如果我们构建并发送以下 HTTP 请求以通过将page值指定为 1 来检索所有消息的第一页,API 将提供之前显示的相同结果:
http ':5000/api/messages/?page=1'
以下是对应的 curl 命令:
curl -iX GET ':5000/api/messages/?page=1'
小贴士
PaginationHelper类中的代码认为第一页是页码 1。因此,我们不对页码使用基于 0 的编号。
现在,我们将构建并发送一个 HTTP 请求来检索下一页,即消息的第二页,具体是一个到/api/messages/的 HTTP GET方法,将page值设置为2。请记住,前一个结果 JSON 体中返回的next键的值为我们提供了到下一页的完整 URL:
http ':5000/api/messages/?page=2'
以下是对应的curl命令:
curl -iX GET ':5000/api/messages/?page=2'
结果将为我们提供五条消息资源的第二批(results键),查询的消息总数(count键),到下一页(next键)和上一页(previous键)的链接。在这种情况下,结果集是第二页,因此,到上一页的链接(previous键)是http://localhost:5000/api/messages/?page=1。我们将在响应头中收到200 OK状态码,并在results数组中收到 5 条消息。
HTTP/1.0 200 OK
Content-Length: 2557
Content-Type: application/json
Date: Wed, 10 Aug 2016 19:51:50 GMT
Server: Werkzeug/0.11.10 Python/3.5.1
{
"count": 12,
"next": "http://localhost:5000/api/messages/?page=3",
"previous": "http://localhost:5000/api/messages/?page=1",
"results": [
{
"category": {
"id": 1,
"name": "Information",
"url": "http://localhost:5000/api/categories/1"
},
"creation_date": "2016-08-09T20:19:22.335600+00:00",
"duration": 18,
"id": 6,
"message": "Checking pressure sensor",
"printed_once": false,
"printed_times": 0,
"url": "http://localhost:5000/api/messages/6"
},
{
"category": {
"id": 1,
"name": "Information",
"url": "http://localhost:5000/api/categories/1"
},
"creation_date": "2016-08-09T20:19:26.189009+00:00",
"duration": 14,
"id": 7,
"message": "Checking gas sensor",
"printed_once": false,
"printed_times": 0,
"url": "http://localhost:5000/api/messages/7"
},
{
"category": {
"id": 1,
"name": "Information",
"url": "http://localhost:5000/api/categories/1"
},
"creation_date": "2016-08-09T20:19:29.854576+00:00",
"duration": 22,
"id": 8,
"message": "Setting ADC resolution",
"printed_once": false,
"printed_times": 0,
"url": "http://localhost:5000/api/messages/8"
},
{
"category": {
"id": 1,
"name": "Information",
"url": "http://localhost:5000/api/categories/1"
},
"creation_date": "2016-08-09T20:19:33.838977+00:00",
"duration": 15,
"id": 9,
"message": "Setting sample rate",
"printed_once": false,
"printed_times": 0,
"url": "http://localhost:5000/api/messages/9"
},
{
"category": {
"id": 1,
"name": "Information",
"url": "http://localhost:5000/api/categories/1"
},
"creation_date": "2016-08-09T20:19:37.830843+00:00",
"duration": 18,
"id": 10,
"message": "Initializing pressure sensor",
"printed_once": false,
"printed_times": 0,
"url": "http://localhost:5000/api/messages/10"
}
]
}
最后,我们将构建并发送一个 HTTP 请求来检索最后一页,即消息的第三页,具体是一个到/api/messages/的 HTTP GET方法,将page值设置为3。请记住,前一个结果 JSON 体中返回的next键的值为我们提供了到下一页的 URL:
http ':5000/api/messages/?page=3'
以下是对应的 curl 命令:
curl -iX GET ':5000/api/messages/?page=3'
结果将为我们提供最后一批包含两个消息资源(results键),查询的消息总数(count键),到下一页(next键)和上一页(previous键)的链接。在这种情况下,结果集是最后一页,因此,下一页的链接(next键)是null。我们将在响应头中收到200 OK状态码,并在results数组中收到 2 条消息:
HTTP/1.0 200 OK
Content-Length: 1090
Content-Type: application/json
Date: Wed, 10 Aug 2016 20:02:00 GMT
Server: Werkzeug/0.11.10 Python/3.5.1
{
"count": 12,
"next": null,
"previous": "http://localhost:5000/api/messages/?page=2",
"results": [
{
"category": {
"id": 1,
"name": "Information",
"url": "http://localhost:5000/api/categories/1"
},
"creation_date": "2016-08-09T20:19:41.645628+00:00",
"duration": 16,
"id": 11,
"message": "Initializing gas sensor",
"printed_once": false,
"printed_times": 0,
"url": "http://localhost:5000/api/messages/11"
},
{
"category": {
"id": 1,
"name": "Information",
"url": "http://localhost:5000/api/categories/1"
},
"creation_date": "2016-08-09T20:19:45.304391+00:00",
"duration": 5,
"id": 12,
"message": "Initializing proximity sensor",
"printed_once": false,
"printed_times": 0,
"url": "http://localhost:5000/api/messages/12"
}
]
}
理解添加身份验证和权限的步骤
我们当前版本的 API 处理所有传入的请求,无需任何类型的身份验证。我们将使用 Flask 扩展和其他包来使用 HTTP 身份验证方案来识别发起请求的用户或签名请求的令牌。然后,我们将使用这些凭据来应用权限,这将决定请求是否必须被允许。不幸的是,Flask 和 Flask-RESTful 都没有提供我们可以轻松插入和配置的身份验证框架。因此,我们将不得不编写代码来执行与身份验证和权限相关的许多任务。
我们希望能够在没有任何身份验证的情况下创建新用户。然而,所有其他 API 调用都只为已认证的用户提供。
首先,我们将安装一个 Flask 扩展以使我们更容易处理 HTTP 身份验证,Flask-HTTPAuth,以及一个允许我们对密码进行散列并检查提供的密码是否有效的包,passlib。
我们将创建一个新的User模型来表示用户。该模型将提供方法,使我们能够对密码进行散列并验证提供给用户的密码是否有效。我们将创建一个UserSchema类来指定我们想要如何序列化和反序列化用户。
然后,我们将配置 Flask 扩展以与我们的User模型一起工作,以验证密码并设置与请求关联的已认证用户。我们将对现有资源进行更改以要求身份验证,并将新资源添加以允许我们检索现有用户并创建一个新的用户。最后,我们将配置与用户相关的资源路由。
一旦我们完成了之前提到的任务,我们将运行迁移来生成新的表,该表将持久化数据库中的用户。然后,我们将编写并发送 HTTP 请求以了解我们的新版本 API 中的身份验证和权限是如何工作的。
确保您已退出 Flask 开发服务器。请记住,您只需在终端或正在运行的命令提示符窗口中按Ctrl + C即可。现在是运行许多命令的时候了,这些命令对 macOS、Linux 或 Windows 都适用。我们可以使用单个命令使用 pip 安装所有必要的包。然而,我们将运行两个独立的命令,以便在特定安装失败时更容易检测到任何问题。
现在,我们必须运行以下命令来使用 pip 安装 Flask-HTTPAuth。这个包使得向任何 Flask 应用程序添加基本 HTTP 身份验证变得简单:
pip install Flask-HTTPAuth
输出的最后几行将指示Flask-HTTPAuth包已成功安装:
Installing collected packages: Flask-HTTPAuth
Running setup.py install for Flask-HTTPAuth
Successfully installed Flask-HTTPAuth-3.2.1
运行以下命令使用 pip 安装 passlib。这个包是一个流行的包,它提供了一个支持 30 多种方案的全面密码散列框架。我们绝对不想编写我们自己的容易出错且可能非常不安全的密码散列代码,因此,我们将利用一个提供这些服务的库:
pip install passlib
输出的最后几行将指示passlib包已成功安装:
Installing collected packages: passlib
Successfully installed passlib-1.6.5
添加用户模型
现在,我们将创建我们将用来表示和持久化用户的模型。打开api/models.py文件,在AddUpdateDelete类的声明之后添加以下行。确保添加导入语句。示例代码文件包含在restful_python_chapter_07_02文件夹中:
from passlib.apps import custom_app_context as password_context
import re
class User(db.Model, AddUpdateDelete):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(50), unique=True, nullable=False)
# I save the hashed password
hashed_password = db.Column(db.String(120), nullable=False)
creation_date = db.Column(db.TIMESTAMP, server_default=db.func.current_timestamp(), nullable=False)
def verify_password(self, password):
return password_context.verify(password, self.hashed_password)
def check_password_strength_and_hash_if_ok(self, password):
if len(password) < 8:
return 'The password is too short', False
if len(password) > 32:
return 'The password is too long', False
if re.search(r'[A-Z]', password) is None:
return 'The password must include at least one uppercase letter',
False
if re.search(r'[a-z]', password) is None:
return 'The password must include at least one lowercase letter',
False
if re.search(r'\d', password) is None:
return 'The password must include at least one number', False
if re.search(r"[ !#$%&'()*+,-./[\\\]^_`{|}~"+r'"]', password) is None:
return 'The password must include at least one symbol', False
self.hashed_password = password_context.encrypt(password)
return '', True
def __init__(self, name):
self.name = name
代码声明了User模型,具体是db.Model和AddUpdateDelete类的子类。我们指定了以下三个属性的字段类型、最大长度和默认值-id、name、hashed_password和creation_date。这些属性代表没有关系的字段,因此它们是db.Column类的实例。模型声明了一个id属性,并指定了primary_key参数的True值,以指示它是主键。SQLAlchemy 将使用这些数据在 PostgreSQL 数据库中生成必要的表。
User类声明了以下方法:
check_password_strength_and_hash_if_ok: 此方法使用re模块提供的正则表达式匹配操作来检查作为参数接收的password是否满足许多定性要求。代码要求密码长度超过八个字符,最大为 32 个字符。密码必须包含至少一个大写字母、一个小写字母、一个数字和一个符号。代码通过多次调用re.search方法来检查接收到的密码是否满足每个要求。如果任何要求未满足,代码将返回一个包含错误消息和False的元组。否则,代码将调用导入为password_context的passlib.apps.custom_app_context实例的encrypt方法,并将接收到的password作为参数。encrypt方法根据平台选择一个合理的强加密方案,默认设置轮数选择,并将加密后的密码保存在hash_password属性中。最后,代码返回一个包含空字符串和True的元组,表示密码满足了定性要求并且已被加密。
提示
默认情况下,passlib库将为 64 位平台使用 SHA-512 方案,为 32 位平台使用 SHA-256 方案。此外,最小轮数将设置为 535,000。我们将使用此示例的默认配置值。然而,你必须考虑到这些值可能需要太多的处理时间来验证每个请求的密码。你应该根据你的安全要求选择最合适的算法和轮数。
verify_password: 此方法调用导入为password_context的passlib.apps.custom_app_context实例的verify方法,将接收到的password和存储的用户哈希密码self.hashed_password作为参数。verify方法对接收到的密码进行哈希处理,只有当接收到的哈希密码与存储的哈希密码匹配时才返回True。我们永远不会恢复保存的密码到其原始状态。我们只是比较哈希值。
模型声明了一个构造函数,即__init__方法。这个构造函数接收name参数中的用户名并将其保存为具有相同名称的属性。
创建用于验证、序列化和反序列化用户的模式
现在,我们将创建 Flask-Marshmallow 模式,我们将使用它来验证、序列化和反序列化之前声明的User模型。打开api/models.py文件,在现有行之后添加以下代码。示例的代码文件包含在restful_python_chapter_07_02文件夹中:
class UserSchema(ma.Schema):
id = fields.Integer(dump_only=True)
name = fields.String(required=True, validate=validate.Length(3))
url = ma.URLFor('api.userresource', id='<id>', _external=True)
代码声明了UserSchema模式,具体是ma.Schema类的子类。记住,我们之前为api/models.py文件编写的代码创建了一个名为ma的flask_marshmallow.Mashmallow实例。
我们将表示字段的属性声明为marshmallow.fields模块中声明的适当类的实例。UserSchema类将name属性声明为fields.String的实例。required参数设置为True,以指定该字段不能为空字符串。validate参数设置为validate.Length(3),以指定该字段必须至少有 3 个字符长。
密码验证未包含在模式中。我们将使用在User类中定义的check_password_strength_and_hash_if_ok方法来验证密码。
为资源添加身份验证
我们将配置Flask-HTTPAuth扩展与我们的User模型一起工作,以验证密码并设置与请求关联的已验证用户。我们将声明一个自定义函数,该扩展将使用该函数作为回调来验证密码。我们将为需要身份验证的资源创建一个新的基类。打开api/views.py文件,在最后一个使用import语句的行之后和声明Blueprint实例的行之前添加以下代码。示例的代码文件包含在restful_python_chapter_07_02文件夹中:
from flask_httpauth import HTTPBasicAuth
from flask import g
from models import User, UserSchema
auth = HTTPBasicAuth()
@auth.verify_password
def verify_user_password(name, password):
user = User.query.filter_by(name=name).first()
if not user or not user.verify_password(password):
return False
g.user = user
return True
class AuthRequiredResource(Resource):
method_decorators = [auth.login_required]
首先,我们创建了一个名为auth的flask_httpauth.HTTPBasicAuth类的实例。然后,我们声明了一个接收名称和密码作为参数的verify_user_password函数。该函数使用@auth.verify_password装饰器使该函数成为Flask-HTTPAuth将用于验证特定用户密码的回调函数。该函数检索与参数中指定的name匹配的用户,并将它的引用保存到user变量中。如果找到用户,代码将检查user.verify_password方法的结果与接收到的密码作为参数。
如果找不到用户或user.verify_password的调用返回False,则该函数返回False,身份验证将失败。如果user.verify_password的调用返回True,则该函数将已验证的User实例存储在flask.g对象的user属性中。
小贴士
flask.g 对象是一个代理,它允许我们在这个对象上存储我们只想在单个请求中共享的任何内容。我们添加到 flask.g 对象中的 user 属性将只对活动请求有效,并且它将为每个不同的请求返回不同的值。这样,就可以在请求期间调用的另一个函数或方法中使用 flask.g.user 来访问有关已验证用户的详细信息。
最后,我们将 AuthRequiredResource 类声明为 flask_restful.Resource 的子类。我们只需将 auth.login_required 指定为从基类继承的 method_decorators 属性所分配的列表中的一个成员。这样,使用新的 AuthRequiredResource 类作为其超类的资源中声明的所有方法都将应用 auth.login_required 装饰器,因此,调用资源的任何方法都需要进行身份验证。
现在,我们将替换现有资源类的基类,使它们从 AuthRequiredResource 继承而不是从 Resource 继承。我们希望任何检索或修改类别和消息的请求都需要进行身份验证。
以下行显示了四个资源类的声明:
class MessageResource(Resource):
class MessageListResource(Resource):
class CategoryResource(Resource):
class CategoryListResource(Resource):
打开 api/views.py 文件,将之前显示的四个声明资源类的行中的 Resource 替换为 AuthRequiredResource。以下行显示了每个资源类声明的新的代码:
class MessageResource(AuthRequiredResource):
class MessageListResource(AuthRequiredResource):
class CategoryResource(AuthRequiredResource):
class CategoryListResource(AuthRequiredResource):
创建资源类来处理用户
我们只想能够创建用户并使用他们来验证请求。因此,我们只需关注创建只有少数方法的资源类。我们不会创建一个完整的用户管理系统。
我们将创建表示用户和用户集合的资源类。首先,我们将创建一个 UserResource 类,我们将使用它来表示用户资源。打开 api/views.py 文件,在创建 Api 实例的行之后添加以下行。示例的代码文件包含在 restful_python_chapter_07_02 文件夹中:
class UserResource(AuthRequiredResource):
def get(self, id):
user = User.query.get_or_404(id)
result = user_schema.dump(user).data
return result
UserResource 类是之前编写的 AuthRequiredResource 类的子类,并声明了一个 get 方法,当具有相同名称的 HTTP 方法作为对表示的资源请求到达时,将调用此方法。该方法接收一个 id 参数,其中包含要检索的用户 id。代码调用 User.query.get_or_404 方法,如果底层数据库中没有具有请求 id 的用户,则返回 HTTP 404 Not Found 状态。如果用户存在,代码将调用 user_schema.dump 方法,并将检索到的用户作为参数传递,以使用 UserSchema 实例序列化与指定 id 匹配的 User 实例。dump 方法接收 User 实例,并应用在 UserSchema 类中指定的字段过滤和输出格式。字段过滤指定我们不想序列化散列密码。代码返回 dump 方法返回结果的 data 属性,即作为主体的 JSON 格式序列化消息,带有默认的 HTTP 200 OK 状态码。
现在,我们将创建一个 UserListResource 类,我们将使用它来表示用户集合。打开 api/views.py 文件,并在创建 UserResource 类的代码之后添加以下行。示例代码文件包含在 restful_python_chapter_07_02 文件夹中:
class UserListResource(Resource):
@auth.login_required
def get(self):
pagination_helper = PaginationHelper(
request,
query=User.query,
resource_for_url='api.userlistresource',
key_name='results',
schema=user_schema)
result = pagination_helper.paginate_query()
return result
def post(self):
request_dict = request.get_json()
if not request_dict:
response = {'user': 'No input data provided'}
return response, status.HTTP_400_BAD_REQUEST
errors = user_schema.validate(request_dict)
if errors:
return errors, status.HTTP_400_BAD_REQUEST
name = request_dict['name']
existing_user = User.query.filter_by(name=name).first()
if existing_user is not None:
response = {'user': 'An user with the same name already exists'}
return response, status.HTTP_400_BAD_REQUEST
try:
user = User(name=name)
error_message, password_ok = \
user.check_password_strength_and_hash_if_ok(request_dict['password'])
if password_ok:
user.add(user)
query = User.query.get(user.id)
result = user_schema.dump(query).data
return result, status.HTTP_201_CREATED
else:
return {"error": error_message}, status.HTTP_400_BAD_REQUEST
except SQLAlchemyError as e:
db.session.rollback()
resp = {"error": str(e)}
return resp, status.HTTP_400_BAD_REQUEST
UserListResource 类是 flask_restful.Resource 的子类,因为我们不希望所有方法都需要身份验证。我们希望能够在未经身份验证的情况下创建新用户,因此,我们只为 get 方法应用 @auth.login_required 装饰器。post 方法不需要身份验证。该类声明了以下两个方法,当具有相同名称的 HTTP 方法作为对表示的资源请求到达时,将调用这些方法:
-
get:此方法返回一个包含数据库中保存的所有User实例的列表。首先,代码调用User.query.all方法检索数据库中持久化的所有User实例。然后,代码调用user_schema.dump方法,并将检索到的消息和many参数设置为True以序列化可迭代的对象集合。dump方法将应用从数据库检索到的每个User实例,并应用在CategorySchema类中指定的字段过滤和输出格式。代码返回dump方法返回结果的data属性,即作为主体的 JSON 格式序列化消息,带有默认的 HTTP200 OK状态码。 -
post:此方法检索 JSON 主体中接收到的键值对,创建一个新的User实例并将其持久化到数据库中。首先,代码调用request.get_json方法来检索作为请求参数接收到的键值对。然后,代码调用user_schema.validate方法来验证使用检索到的键值对构建的新用户。在这种情况下,对这个方法的调用将仅验证用户的name字段。如果有验证错误,代码将返回验证错误和 HTTP400 Bad Request状态。如果验证成功,代码将检查数据库中是否已存在具有相同名称的用户,以返回适当的错误,该字段必须是唯一的。如果用户名是唯一的,代码将创建一个新的用户,并调用其check_password_strength_and_hash_if_ok方法。如果提供的密码满足所有质量要求,代码将使用其散列密码在数据库中持久化用户。最后,代码以 JSON 格式返回序列化的已保存用户作为主体,并带有 HTTP201 Created状态码:
以下表格显示了与我们之前创建的与用户相关的类相关的方法,我们希望在每个HTTP动词和作用域的组合中执行。
| HTTP 动词 | 作用域 | 类和方法 | 需要认证 |
|---|---|---|---|
GET |
用户集合 | UserListResource.get | 是 |
GET |
用户 | UserResource.get | 是 |
POST |
用户集合 | UserListResource.post | 否 |
我们必须进行必要的资源路由配置,通过定义 URL 规则来调用适当的方法,并通过传递所有必要的参数。以下行配置了与 API 相关的用户相关资源的资源路由。打开api/views.py文件,并在代码末尾添加以下行。示例代码文件包含在restful_python_chapter_07_02文件夹中:
api.add_resource(UserListResource, '/users/')
api.add_resource(UserResource, '/users/<int:id>')
每次调用api.add_resource方法都会将一个 URL 路由到之前编写的用户相关资源之一。当有 API 请求,并且 URL 与api.add_resource方法中指定的 URL 之一匹配时,Flask 将调用与请求中指定的类匹配的HTTP动词的方法。
正在运行迁移以生成用户表
现在,我们将运行多个脚本来运行迁移并生成 PostgreSQL 数据库中必要的表。确保你在激活了虚拟环境并位于api文件夹的终端或命令提示符窗口中运行这些脚本。
运行第一个脚本,将模型中检测到的更改填充到迁移脚本中。在这种情况下,这是我们第二次填充迁移脚本,因此迁移脚本将生成新的表,以持久化我们的新User模型:model:
python migrate.py db migrate
以下行显示了运行上一条脚本后生成的示例输出。您的输出将根据您创建虚拟环境的基准文件夹而有所不同。
INFO [alembic.runtime.migration] Context impl PostgresqlImpl.
INFO [alembic.runtime.migration] Will assume transactional DDL.
INFO [alembic.autogenerate.compare] Detected added table 'user'
INFO [alembic.ddl.postgresql] Detected sequence named 'message_id_seq' as owned by integer column 'message(id)', assuming SERIAL and omitting
Generating
/Users/gaston/PythonREST/Flask02/api/migrations/versions/c8c45e615f6d_.py ... done
输出指示 api/migrations/versions/c8c45e615f6d_.py 文件包含了创建 user 表的代码。以下行显示了基于模型自动生成的此文件的代码。请注意,在您的配置中文件名可能会有所不同。示例的代码文件包含在 restful_python_chapter_06_01 文件夹中:
"""empty message
Revision ID: c8c45e615f6d
Revises: 417543056ac3
Create Date: 2016-08-11 17:31:44.989313
"""
# revision identifiers, used by Alembic.
revision = 'c8c45e615f6d'
down_revision = '417543056ac3'
from alembic import op
import sqlalchemy as sa
def upgrade():
### commands auto generated by Alembic - please adjust! ###
op.create_table('user',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=50), nullable=False),
sa.Column('hashed_password', sa.String(length=120), nullable=False),
sa.Column('creation_date', sa.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('name')
)
### end Alembic commands ###
def downgrade():
### commands auto generated by Alembic - please adjust! ###
op.drop_table('user')
### end Alembic commands ###
代码定义了两个函数:upgrade 和 downgrade。upgrade 函数通过调用 alembic.op.create_table 来执行必要的代码以创建 user 表。downgrade 函数运行必要的代码以回到上一个版本。
运行第二个脚本以升级数据库:
python migrate.py db upgrade
以下行显示了运行上一条脚本后生成的示例输出:
INFO [alembic.runtime.migration] Context impl PostgresqlImpl.
INFO [alembic.runtime.migration] Will assume transactional DDL.
INFO [alembic.runtime.migration] Running upgrade 417543056ac3 ->
c8c45e615f6d, empty message
上一条脚本调用了自动生成的 api/migrations/versions/c8c45e615f6d_.py 脚本中定义的 upgrade 函数。别忘了,在您的配置中文件名可能会有所不同。
在我们运行上一条脚本后,我们可以使用 PostgreSQL 命令行或任何允许我们轻松验证 PostgreSQL 数据库内容的其他应用程序来检查迁移生成的新的表。运行以下命令以列出生成的表。如果您使用的数据库名称不是 messages,请确保您使用适当的数据库名称:
psql --username=user_name --dbname=messages --command="\dt"
以下行显示了包含所有生成表名的输出。迁移升级生成了一个名为 user 的新表。
**List of relations**
**Schema | Name | Type | Owner**
**--------+-----------------+-------+-----------**
**public | alembic_version | table | user_name**
**public | category | table | user_name**
**public | message | table | user_name**
**public | user | table | user_name**
(4 rows)
SQLAlchemy 根据我们 User 模型中包含的信息生成了用户表,其中包含主键、对名称字段和密码字段的唯一约束。
以下命令将在我们向 RESTful API 发送 HTTP 请求并创建新用户后,允许您检查用户表的内容。这些命令假设您正在同一台计算机上运行 PostgreSQL 和命令:
psql --username=user_name --dbname=messages --command="SELECT * FROM
public.user;"
现在,我们可以运行 api/run.py 脚本以启动 Flask 的开发。在 api 文件夹中执行以下命令:
python run.py
执行上一条命令后,开发服务器将在端口 5000 上开始监听。
编写带有必要身份验证的请求
现在,我们将编写并发送一个不包含身份验证凭据的 HTTP 请求以检索消息的第一页:
http POST ':5000/api/messages/?page=1'
以下是对应的 curl 命令:
curl -iX GET ':5000/api/messages/?page=1'
我们将在响应头中收到 401 未授权 状态码。以下行显示了示例响应:
HTTP/1.0 401 UNAUTHORIZED
Content-Length: 19
Content-Type: text/html; charset=utf-8
Date: Mon, 15 Aug 2016 01:16:36 GMT
Server: Werkzeug/0.11.10 Python/3.5.1
WWW-Authenticate: Basic realm="Authentication Required"
如果我们想检索消息,即对 /api/messages/ 进行 GET 请求,我们需要使用 HTTP 身份验证提供身份验证凭据。然而,在我们能够这样做之前,有必要创建一个新的用户。我们将使用这个新用户来测试我们与用户相关的新的资源类以及我们在权限策略中的更改。
http POST :5000/api/users/ name='brandon' password='brandonpassword'
以下是对应的 curl 命令:
curl -iX POST -H "Content-Type: application/json" -d '{"name": "brandon",
"password": "brandonpassword"}' :5000/api/users/
小贴士
当然,创建用户和执行需要身份验证的方法只能在 HTTPS 下进行。这样,用户名和密码就会被加密。
之前的命令将编写并发送一个带有指定 JSON 键值对的 POST HTTP 请求。请求指定 /api/user/,因此它将匹配 '/users/' URL 路由的 UserList 资源,并运行不需要身份验证的 UserList.post 方法。由于 URL 路由不包含任何参数,该方法不接收任何参数。由于请求的 HTTP 动词是 POST,Flask 调用 post 方法。
之前指定的密码仅包含小写字母,因此它不符合我们在 User.check_password_strength_and_hash_if_ok 方法中指定的密码的所有质量要求。因此,我们将在响应头中收到 400 Bad Request 状态码,并在 JSON 主体中显示未满足要求的错误消息。以下行显示了示例响应:
HTTP/1.0 400 BAD REQUEST
Content-Length: 75
Content-Type: application/json
Date: Mon, 15 Aug 2016 01:29:55 GMT
Server: Werkzeug/0.11.10 Python/3.5.1
{
"error": "The password must include at least one uppercase letter"
}
以下命令将创建一个具有有效密码的用户:
http POST :5000/api/users/ name='brandon' password='iA4!V3riS#c^R9'
以下是对应的 curl 命令:
curl -iX POST -H "Content-Type: application/json" -d '{"name": "brandon", "password": "iA4!V3riS#c^R9"}' :5000/api/users/
如果新的 User 实例成功持久化到数据库中,调用将返回 HTTP 201 Created 状态码,并在响应体中将最近持久化的 User 序列化为 JSON。以下行显示了 HTTP 请求的示例响应,其中包含 JSON 响应中的新 User 对象。请注意,响应包括创建用户的 URL,即 url,并且不包含任何与密码相关的信息。
HTTP/1.0 201 CREATED
Content-Length: 87
Content-Type: application/json
Date: Mon, 15 Aug 2016 01:33:23 GMT
Server: Werkzeug/0.11.10 Python/3.5.1
{
"id": 1,
"name": "brandon",
"url": "http://localhost:5000/api/users/1"
}
我们可以运行之前解释的命令来检查在 PostgreSQL 数据库中由迁移创建的 user 表的内容。我们会注意到 hashed_password 字段的内容为新行在 user 表中进行了哈希处理。以下截图显示了在运行 HTTP 请求后 PostgreSQL 数据库中 user 表新行的内容:

如果我们想检索第一页的消息,即对 /api/messages/ 进行 GET 请求,我们需要使用 HTTP 身份验证提供身份验证凭据。现在,我们将编写并发送一个带有身份验证凭据的 HTTP 请求来检索第一页的消息,即使用我们最近创建的用户名和密码:
http -a 'brandon':'iA4!V3riS#c^R9' ':5000/api/messages/?page=1'
以下是对应的 curl 命令:
curl --user 'brandon':'iA4!V3riS#c^R9' -iX GET ':5000/api/messages/?page=1'
用户将成功认证,我们将能够处理请求以检索消息的第一页。在我们对 API 所做的所有更改中,未认证的请求只能创建新用户。
测试你的知识
-
flask.g对象是:-
一个提供对当前请求访问的代理。
-
flask_httpauth.HTTPBasicAuth类的一个实例。 -
一个代理,允许我们仅存储一次请求中想要共享的内容。
-
-
passlib包提供:-
一个支持超过 30 种方案的密码哈希框架。
-
一个认证框架,它自动将用户和权限模型添加到 Flask 应用程序中。
-
一个轻量级 Web 框架,用于替代 Flask。
-
-
应用到函数上的
auth.verify_password装饰器:-
使此函数成为
Flask-HTTPAuth将用于为特定用户哈希密码的回调函数。 -
使此函数成为
SQLAlchemy将用于验证特定用户密码的回调函数。 -
使此函数成为
Flask-HTTPAuth将用于验证特定用户密码的回调函数。
-
-
当你将包含
auth.login_required的列表分配给flask_restful.Resource任何子类的method_decorators属性时,考虑到 auth 是flask_httpauth.HTTPBasicAuth()的一个实例:-
资源中声明的所有方法都将应用
auth.login_required装饰器。 -
资源中声明的
post方法将应用auth.login_required装饰器。 -
在资源中声明的以下任何方法都将应用
auth.login_required装饰器:delete、patch、post和put。
-
-
在以下代码行中,考虑到代码将在
flask_restful.Resource类子类的定义方法中运行,以下哪一行从请求对象中检索'page'参数的整数值?-
page_number = request.get_argument('page', 1, type=int) -
page_number = request.args.get('page', 1, type=int) -
page_number = request.arguments.get('page', 1, type=int)
-
摘要
在本章中,我们从许多方面改进了 RESTful API。当资源不唯一时,我们添加了用户友好的错误消息。我们测试了如何使用PATCH方法更新单个或多个字段,并创建了我们自己的通用分页类。
然后,我们开始处理认证和权限。我们添加了用户模型并更新了数据库。我们在不同的代码片段中进行了许多更改,以实现特定的安全目标,并利用 Flask-HTTPAuth 和 passlib 在我们的 API 中使用 HTTP 认证。
现在我们已经构建了一个改进的复杂 API,该 API 使用分页和认证,我们将使用框架中包含的额外抽象,我们将编写、执行和改进单元测试,这是我们将在下一章讨论的内容。
第八章:使用 Flask 测试和部署 API
在本章中,我们将配置、编写和执行单元测试,并学习一些与部署相关的内容。我们将:
-
设置单元测试
-
为测试创建数据库
-
编写第一轮单元测试
-
运行单元测试并检查测试覆盖率
-
提高测试覆盖率
-
理解部署和可扩展性的策略
设置单元测试
我们将使用 nose2 来简化单元测试的发现和运行。我们将测量测试覆盖率,因此,我们将安装必要的包以允许我们使用 nose2 运行覆盖率。首先,我们将在我们的虚拟环境中安装 nose2 和 cov-core 包。cov-core 包将允许我们使用 nose2 测量测试覆盖率。然后,我们将创建一个新的 PostgreSQL 数据库,我们将用它来进行测试。最后,我们将创建测试环境的配置文件。
确保您退出 Flask 的开发服务器。请记住,您只需在运行它的终端或命令提示符窗口中按 Ctrl + C 即可。我们只需运行以下命令即可安装 nose2 包:
pip install nose2
输出的最后几行将指示 django-nose 包已成功安装。
Collecting nose2
Collecting six>=1.1 (from nose2)
Downloading six-1.10.0-py2.py3-none-any.whl
Installing collected packages: six, nose2
Successfully installed nose2-0.6.5 six-1.10.0
我们只需运行以下命令即可安装 cov-core 包,该包也将安装 coverage 依赖项:
pip install cov-core
输出的最后几行将指示 django-nose 包已成功安装:
Collecting cov-core
Collecting coverage>=3.6 (from cov-core)
Installing collected packages: coverage, cov-core
Successfully installed cov-core-1.15.0 coverage-4.2
现在,我们将创建一个 PostgreSQL 数据库,我们将将其用作测试环境的存储库。如果您还没有在您的计算机上的测试环境中或在测试服务器上运行 PostgreSQL 数据库,您将需要下载并安装 PostgreSQL 数据库。
小贴士
请记住确保 PostgreSQL 的 bin 文件夹包含在 PATH 环境变量中。您应该能够从当前的 Terminal 或 Command Prompt 执行 psql 命令行实用程序。
我们将使用 PostgreSQL 命令行工具创建一个名为 test_messages 的新数据库。如果您已经有一个同名 PostgreSQL 数据库,请确保在所有命令和配置中使用另一个名称。您可以使用任何 PostgreSQL GUI 工具执行相同的任务。如果您在 Linux 上开发,必须以 postgres 用户身份运行命令。在 macOS 或 Windows 上运行以下命令以创建一个名为 test_messages 的新数据库。请注意,该命令不会生成任何输出:
createdb test_messages
在 Linux 上,运行以下命令以使用 postgres 用户:
sudo -u postgres createdb test_messages
现在,我们将使用 psql 命令行工具运行一些 SQL 语句,以授予数据库对用户的权限。如果您使用的是与开发服务器不同的服务器,您必须在授予权限之前创建用户。在 macOS 或 Windows 上,运行以下命令以启动 psql:
psql
在 Linux 上,运行以下命令以使用 postgres 用户
sudo -u psql
然后,运行以下 SQL 语句,最后输入 \q 以退出 psql 命令行工具。将 user_name 替换为你希望在新的数据库中使用的用户名,将 password 替换为你选择的密码。我们将在 Flask 测试配置中使用用户名和密码。如果你已经在 PostgreSQL 中使用特定用户并且已经为该用户授予了数据库权限,则无需运行以下步骤:
GRANT ALL PRIVILEGES ON DATABASE test_messages TO user_name;
\q
在 api 文件夹中创建一个新的 test_config.py 文件。以下行显示了声明变量以确定 Flask 和 SQLAlchemy 测试环境配置的代码。SQL_ALCHEMY_DATABASE_URI 变量生成一个用于 PostgreSQL 数据库的 SQLAlchemy URI,我们将使用它来运行所有迁移,并在开始测试之前删除所有元素。确保你指定了 DB_NAME 值中所需的测试数据库名称,并根据你的测试环境 PostgreSQL 配置配置用户、密码、主机和端口。如果你遵循了之前的步骤,请使用这些步骤中指定的设置。示例的代码文件包含在 restful_python_chapter_08_01 文件夹中。
import os
basedir = os.path.abspath(os.path.dirname(__file__))
DEBUG = True
PORT = 5000
HOST = "127.0.0.1"
SQLALCHEMY_ECHO = False
SQLALCHEMY_TRACK_MODIFICATIONS = True
SQLALCHEMY_DATABASE_URI = "postgresql://{DB_USER}:{DB_PASS}@{DB_ADDR}/{DB_NAME}".format(DB_USER="user_name", DB_PASS="password", DB_ADDR="127.0.0.1", DB_NAME="test_messages")
SQLALCHEMY_MIGRATE_REPO = os.path.join(basedir, 'db_repository')
TESTING = True
SERVER_NAME = '127.0.0.1:5000'
PAGINATION_PAGE_SIZE = 5
PAGINATION_PAGE_ARGUMENT_NAME = 'page'
#Disable CSRF protection in the testing configuration
WTF_CSRF_ENABLED = False
正如我们在为开发环境创建的类似测试文件中做的那样,我们将指定之前创建的模块作为函数的参数,该函数将创建一个我们将用于测试的 Flask 应用程序。这样,我们有一个模块指定了测试环境中所有不同配置变量的值,另一个模块为我们的测试环境创建一个 Flask 应用程序。也可以创建一个类层次结构,每个环境都有一个类。然而,在我们的示例中,为测试环境创建一个新的配置文件更容易。
编写第一轮单元测试
现在,我们将编写第一轮单元测试。具体来说,我们将编写与用户和消息类别资源相关的单元测试:UserResource、UserListResource、CategoryResource 和 CategoryListResource。在 api 文件夹中创建一个新的 tests 子文件夹。然后,在新的 api/tests 子文件夹中创建一个新的 test_views.py 文件。添加以下行,这些行声明了许多 import 语句和 InitialTests 类的第一个方法。示例的代码文件包含在 restful_python_chapter_08_01 文件夹中:
from app import create_app
from base64 import b64encode
from flask import current_app, json, url_for
from models import db, Category, Message, User
import status
from unittest import TestCase
class InitialTests(TestCase):
def setUp(self):
self.app = create_app('test_config')
self.test_client = self.app.test_client()
self.app_context = self.app.app_context()
self.app_context.push()
self.test_user_name = 'testuser'
self.test_user_password = 'T3s!p4s5w0RDd12#'
db.create_all()
def tearDown(self):
db.session.remove()
db.drop_all()
self.app_context.pop()
def get_accept_content_type_headers(self):
return {
'Accept': 'application/json',
'Content-Type': 'application/json'
}
def get_authentication_headers(self, username, password):
authentication_headers = self.get_accept_content_type_headers()
authentication_headers['Authorization'] = \
'Basic ' + b64encode((username + ':' + password).encode('utf-
8')).decode('utf-8')
return authentication_headers
InitialTests 类是 unittest.TestCase 的子类。该类覆盖了在每次测试方法运行之前将执行的 setUp 方法。该方法使用 'test_config' 作为参数调用在 app 模块中声明的 create_app 函数。该函数将使用此模块作为配置文件设置 Flask 应用程序,因此应用程序将使用之前创建的配置文件,该文件指定了测试数据库和环境所需的值。然后,代码将新创建的 app 的测试属性设置为 True,以便异常可以传播到测试客户端。
下一个调用 self.app.test_client 方法来为之前创建的 Flask 应用程序创建一个测试客户端,并将测试客户端保存到 test_client 属性中。我们将在测试方法中使用测试客户端来轻松地组合和发送请求到我们的 API。然后,代码保存并推送应用程序上下文,并创建两个属性,包含我们将用于测试的用户名和密码。最后,方法调用 db.create_all 方法来创建在 test_config.py 文件中配置的测试数据库中所有必要的表。
InitialTests 类覆盖了在每次测试方法运行之后将执行的 tearDown 方法。代码移除 SQLAlchemy 会话,在测试数据库中删除我们在测试执行前创建的所有表,并弹出应用程序上下文。这样,每次测试完成后,测试数据库将再次为空。
get_accept_content_type_headers 方法构建并返回一个字典(dict),其中 Accept 和 Content-Type 头键的值设置为 'application/json'。在我们需要构建头部以组合无需认证的请求时,我们将在测试中调用此方法。
get_authentication_headers 方法调用之前解释过的 get_accept_content_type_headers 方法来生成无需认证的头部键值对。然后,代码将必要的值添加到 Authorization 键中,使用适当的编码来提供在 username 和 password 参数中接收的用户名和密码。最后一行返回包含认证信息的生成字典。在我们需要构建头部以添加认证来组合请求时,我们将在测试中调用此方法。我们将使用在 setUp 方法中存储的用户名和密码。
打开之前创建的 test_views.py 文件,位于新的 api/tests 子文件夹中。添加以下行,这些行声明了 InitialTests 类的多个方法。示例代码文件包含在 restful_python_chapter_08_01 文件夹中。
def test_request_without_authentication(self):
"""
Ensure we cannot access a resource that requirest authentication without an appropriate authentication header
"""
response = self.test_client.get(
url_for('api.messagelistresource', _external=True),
headers=self.get_accept_content_type_headers())
self.assertTrue(response.status_code == status.HTTP_401_UNAUTHORIZED)
def create_user(self, name, password):
url = url_for('api.userlistresource', _external=True)
data = {'name': name, 'password': password}
response = self.test_client.post(
url,
headers=self.get_accept_content_type_headers(),
data=json.dumps(data))
return response
def create_category(self, name):
url = url_for('api.categorylistresource', _external=True)
data = {'name': name}
response = self.test_client.post(
url,
headers=self.get_authentication_headers(self.test_user_name,
self.test_user_password),
data=json.dumps(data))
return response
def test_create_and_retrieve_category(self):
"""
Ensure we can create a new Category and then retrieve it
"""
create_user_response = self.create_user(self.test_user_name,
self.test_user_password)
self.assertEqual(create_user_response.status_code,
status.HTTP_201_CREATED)
new_category_name = 'New Information'
post_response = self.create_category(new_category_name)
self.assertEqual(post_response.status_code, status.HTTP_201_CREATED)
self.assertEqual(Category.query.count(), 1)
post_response_data = json.loads(post_response.get_data(as_text=True))
self.assertEqual(post_response_data['name'], new_category_name)
new_category_url = post_response_data['url']
get_response = self.test_client.get(
new_category_url,
headers=self.get_authentication_headers(self.test_user_name,
self.test_user_password))
get_response_data = json.loads(get_response.get_data(as_text=True))
self.assertEqual(get_response.status_code, status.HTTP_200_OK)
self.assertEqual(get_response_data['name'], new_category_name)
test_request_without_authentication 方法测试当我们不提供适当的身份验证头时,我们是否被拒绝访问需要身份验证的资源。该方法使用测试客户端来组合并发送一个 HTTP GET 请求到为 'api.messagelistresource' 资源生成的 URL,以检索消息列表。我们需要一个经过身份验证的请求来检索消息列表。然而,代码调用 get_authentication_headers 方法来设置调用 self.test_client.get 时的 headers 参数的值,因此代码生成了一个没有身份验证的请求。最后,该方法使用 assertTrue 来检查响应的 status_code 是否为 HTTP 401 未授权 (status.HTTP_401_UNAUTHORIZED)。
create_user 方法使用测试客户端来组合并发送一个 HTTP POST 请求到为 'api.userlistresource' 资源生成的 URL,以创建一个名为和密码作为参数的新用户。我们不需要经过身份验证的请求来创建一个新用户,因此代码调用之前解释过的 get_accept_content_type_headers 方法来设置调用 self.test_client.post 时的 headers 参数的值。最后,代码返回 POST 请求的响应。每次我们需要创建一个经过身份验证的请求时,我们将调用 create_user 方法来创建一个新用户。
create_category 方法使用测试客户端来组合并发送一个 HTTP POST 请求到为 'api.categorylistresource' 资源生成的 URL,以创建一个名为参数接收的新 Category。我们需要一个经过身份验证的请求来创建一个新的 Category,因此代码调用之前解释过的 get_authentication_headers 方法来设置调用 self.test_client.post 时的 headers 参数的值。用户名和密码设置为 self.test_user_name 和 self.test_user_password。最后,代码返回 POST 请求的响应。每次我们需要创建一个类别时,我们将在创建请求身份验证的适当用户之后调用 create_category 方法。
test_create_and_retrieve_category 方法测试我们是否可以创建一个新的 Category 并然后检索它。该方法调用之前解释过的 create_user 方法来创建一个新用户,然后使用它来身份验证 create_game_category 方法生成的 HTTP POST 请求。然后,代码组合并发送一个 HTTP GET 方法来检索之前 POST 请求响应中接收到的 URL 中的最近创建的 Category。该方法使用 assertEqual 来检查以下预期的结果:
-
HTTP
POST响应的status_code是 HTTP 201 已创建 (status.HTTP_201_CREATED) -
从数据库中检索到的
Category对象总数是1 -
HTTP
GET响应的status_code是 HTTP 200 OK (status.HTTP_200_OK) -
HTTP
GET响应中name键的值等于为新类别指定的名称
在新创建的 api/tests 子文件夹中打开之前创建的 test_views.py 文件。添加以下行,这些行声明了 InitialTests 类的许多方法。示例代码文件包含在 restful_python_chapter_08_01 文件夹中。
def test_create_duplicated_category(self):
"""
Ensure we cannot create a duplicated Category
"""
create_user_response = self.create_user(self.test_user_name,
self.test_user_password)
self.assertEqual(create_user_response.status_code,
status.HTTP_201_CREATED)
new_category_name = 'New Information'
post_response = self.create_category(new_category_name)
self.assertEqual(post_response.status_code, status.HTTP_201_CREATED)
self.assertEqual(Category.query.count(), 1)
post_response_data = json.loads(post_response.get_data(as_text=True))
self.assertEqual(post_response_data['name'], new_category_name)
second_post_response = self.create_category(new_category_name)
self.assertEqual(second_post_response.status_code,
status.HTTP_400_BAD_REQUEST)
self.assertEqual(Category.query.count(), 1)
def test_retrieve_categories_list(self):
"""
Ensure we can retrieve the categories list
"""
create_user_response = self.create_user(self.test_user_name,
self.test_user_password)
self.assertEqual(create_user_response.status_code,
status.HTTP_201_CREATED)
new_category_name_1 = 'Error'
post_response_1 = self.create_category(new_category_name_1)
self.assertEqual(post_response_1.status_code, status.HTTP_201_CREATED)
new_category_name_2 = 'Warning'
post_response_2 = self.create_category(new_category_name_2)
self.assertEqual(post_response_2.status_code, status.HTTP_201_CREATED)
url = url_for('api.categorylistresource', _external=True)
get_response = self.test_client.get(
url,
headers=self.get_authentication_headers(self.test_user_name,
self.test_user_password))
get_response_data = json.loads(get_response.get_data(as_text=True))
self.assertEqual(get_response.status_code, status.HTTP_200_OK)
self.assertEqual(len(get_response_data), 2)
self.assertEqual(get_response_data[0]['name'], new_category_name_1)
self.assertEqual(get_response_data[1]['name'], new_category_name_2)
"""
Ensure we can update the name for an existing category
"""
create_user_response = self.create_user(self.test_user_name,
self.test_user_password)
self.assertEqual(create_user_response.status_code,
status.HTTP_201_CREATED)
new_category_name_1 = 'Error 1'
post_response_1 = self.create_category(new_category_name_1)
self.assertEqual(post_response_1.status_code, status.HTTP_201_CREATED)
post_response_data_1 = json.loads(post_response_1.get_data(as_text=True))
new_category_url = post_response_data_1['url']
new_category_name_2 = 'Error 2'
data = {'name': new_category_name_2}
patch_response = self.test_client.patch(
new_category_url,
headers=self.get_authentication_headers(self.test_user_name,
self.test_user_password),
data=json.dumps(data))
self.assertEqual(patch_response.status_code, status.HTTP_200_OK)
get_response = self.test_client.get(
new_category_url,
headers=self.get_authentication_headers(self.test_user_name,
self.test_user_password))
get_response_data = json.loads(get_response.get_data(as_text=True))
self.assertEqual(get_response.status_code, status.HTTP_200_OK)
self.assertEqual(get_response_data['name'], new_category_name_2)
该类声明了以下以 test_ 前缀开头的方法:
-
test_create_duplicated_category:测试唯一约束是否使我们无法创建具有相同名称的两个类别。当我们第二次组合并发送具有重复类别名称的 HTTPPOST请求时,我们必须收到 HTTP 400 Bad Request 状态代码 (status.HTTP_400_BAD_REQUEST),并且从数据库检索到的Category对象总数必须是1。 -
test_retrieve_categories_list:测试我们是否可以检索类别列表。首先,该方法创建两个类别,然后确保检索到的列表包括这两个创建的类别。 -
test_update_game_category:测试我们是否可以更新类别的单个字段,特别是其名称字段。代码确保名称已被更新。
小贴士
注意,每个需要在数据库中满足特定条件的测试都必须执行所有必要的代码,以确保数据库处于这种特定状态。例如,为了更新现有类别,我们首先必须创建一个新的类别,然后才能更新它。每个测试方法将在数据库中不包含之前执行测试方法的数据的情况下执行,也就是说,每个测试将在清理了之前测试数据的数据库上运行。
使用 nose2 运行单元测试并检查测试覆盖率
现在,运行以下命令以在我们的测试数据库中创建所有必要的表,并使用 nose2 测试运行器执行我们创建的所有测试。测试运行器将执行 InitialTests 类中以 test_ 前缀开头的方法,并将显示结果。
小贴士
测试不会更改我们在处理 API 时使用的数据库。请记住,我们已将 test_messages 数据库配置为我们的测试数据库。
从上一章创建的 api.py 文件从 api 文件夹中删除,因为我们不希望测试覆盖率考虑此文件。转到 api 文件夹,并在我们一直在使用的同一虚拟环境中运行以下命令。我们将使用 -v 选项指示 nose2 打印测试用例名称和状态。--with-coverage 选项开启测试覆盖率报告生成:
nose2 -v --with-coverage
以下行显示了示例输出。
test_create_and_retrieve_category (test_views.InitialTests) ... ok
test_create_duplicated_category (test_views.InitialTests) ... ok
test_request_without_authentication (test_views.InitialTests) ... ok
test_retrieve_categories_list (test_views.InitialTests) ... ok
test_update_category (test_views.InitialTests) ... ok
--------------------------------------------------------
Ran 5 tests in 3.973s
OK
----------- coverage: platform win32, python 3.5.2-final-0 --
Name Stmts Miss Cover
-----------------------------------------
app.py 9 0 100%
config.py 11 11 0%
helpers.py 23 18 22%
migrate.py 9 9 0%
models.py 101 27 73%
run.py 4 4 0%
status.py 56 5 91%
test_config.py 12 0 100%
tests\test_views.py 96 0 100%
views.py 204 109 47%
-----------------------------------------
TOTAL 525 183 65%
默认情况下,nose2会查找名称以test前缀开始的模块。在这种情况下,唯一符合标准的模块是test_views模块。在符合标准的模块中,nose2从unittest.TestCase的所有子类以及以test前缀开头名称的函数中加载测试。
输出提供了详细信息,表明测试运行器发现了并执行了五个测试,并且所有测试都通过了。输出显示了InitialTests类中以test_前缀开始的每个方法的名称和类名,这些方法代表要执行的测试。
coverage包提供的测试代码覆盖率测量报告使用 Python 标准库中包含的代码分析工具和跟踪钩子来确定哪些代码行是可执行的并且已经执行。报告提供了一个包含以下列的表格:
-
Name:Python 模块名称。 -
Stmts:Python 模块的执行语句计数。 -
Miss:未执行的执行语句的数量,即那些未执行的语句。 -
Cover:以百分比表示的可执行语句覆盖率。
根据报告中显示的测量结果,我们确实对views.py和helpers.py的覆盖率非常低。实际上,我们只编写了一些与类别和用户相关的测试,因此对于视图的覆盖率真的很低是有道理的。我们没有创建与消息相关的测试。
我们可以使用带有-m命令行选项的coverage命令来显示新Missing列中缺失语句的行号:
coverage report -m
命令将使用上次执行的信息,并显示缺失的语句。下面的几行显示了与之前单元测试执行相对应的示例输出:
Name Stmts Miss Cover Missing
---------------------------------------------------
app.py 9 0 100%
config.py 11 11 0% 7-20
helpers.py 23 18 22% 13-19, 23-44
migrate.py 9 9 0% 7-19
models.py 101 27 73% 28-29, 44, 46, 48, 50, 52, 54, 73-75, 79-86, 103, 127-137
run.py 4 4 0% 7-14
status.py 56 5 91% 2, 6, 10, 14, 18
test_config.py 12 0 100%
tests\test_views.py 96 0 100%
views.py 204 109 47% 43-45, 51-58, 63-64, 67, 71-72, 83-87, 92-94, 97-124, 127-135, 140-147, 150-181, 194-195, 198, 205-206, 209-212, 215-223, 235-236, 239, 250-253
---------------------------------------------------
TOTAL 525 183 65%
现在,运行以下命令以获取详细说明缺失行的注释 HTML 列表:
coverage html
使用您的 Web 浏览器打开在htmlcov文件夹中生成的index.html HTML 文件。以下图片显示了以 HTML 格式生成的示例报告:

点击或轻触views.py,Web 浏览器将渲染一个显示已运行语句、缺失语句和排除语句的 Web 页面,这些语句以不同的颜色显示。我们可以点击或轻触运行、缺失和排除按钮来显示或隐藏代表每行代码状态的背景颜色。默认情况下,缺失的代码行将以粉红色背景显示。因此,我们必须编写针对这些代码行的单元测试来提高我们的测试覆盖率:

提高测试覆盖率
现在,我们将编写额外的单元测试以改进测试覆盖率。具体来说,我们将编写与消息和用户相关的单元测试。打开现有的 api/tests/test_views.py 文件,在 InitialTests 类的最后一条语句之后插入以下行。我们需要一个新的 import 语句,并将声明新的 PlayerTests 类。示例代码文件包含在 restful_python_chapter_08_02 文件夹中:
def create_message(self, message, duration, category):
url = url_for('api.messagelistresource', _external=True)
data = {'message': message, 'duration': duration, 'category': category}
response = self.test_client.post(
url,
headers=self.get_authentication_headers(self.test_user_name,
self.test_user_password),
data=json.dumps(data))
return response
def test_create_and_retrieve_message(self):
"""
Ensure we can create a new message and then retrieve it
"""
create_user_response = self.create_user(self.test_user_name,
self.test_user_password)
self.assertEqual(create_user_response.status_code,
status.HTTP_201_CREATED)
new_message_message = 'Welcome to the IoT world'
new_message_category = 'Information'
post_response = self.create_message(new_message_message, 15,
new_message_category)
self.assertEqual(post_response.status_code, status.HTTP_201_CREATED)
self.assertEqual(Message.query.count(), 1)
# The message should have created a new catagory
self.assertEqual(Category.query.count(), 1)
post_response_data = json.loads(post_response.get_data(as_text=True))
self.assertEqual(post_response_data['message'], new_message_message)
new_message_url = post_response_data['url']
get_response = self.test_client.get(
new_message_url,
headers=self.get_authentication_headers(self.test_user_name,
self.test_user_password))
get_response_data = json.loads(get_response.get_data(as_text=True))
self.assertEqual(get_response.status_code, status.HTTP_200_OK)
self.assertEqual(get_response_data['message'], new_message_message)
self.assertEqual(get_response_data['category']['name'],
new_message_category)
def test_create_duplicated_message(self):
"""
Ensure we cannot create a duplicated Message
"""
create_user_response = self.create_user(self.test_user_name,
self.test_user_password)
self.assertEqual(create_user_response.status_code,
status.HTTP_201_CREATED)
new_message_message = 'Welcome to the IoT world'
new_message_category = 'Information'
post_response = self.create_message(new_message_message, 15,
new_message_category)
self.assertEqual(post_response.status_code, status.HTTP_201_CREATED)
self.assertEqual(Message.query.count(), 1)
post_response_data = json.loads(post_response.get_data(as_text=True))
self.assertEqual(post_response_data['message'], new_message_message)
new_message_url = post_response_data['url']
get_response = self.test_client.get(
new_message_url,
headers=self.get_authentication_headers(self.test_user_name,
self.test_user_password))
get_response_data = json.loads(get_response.get_data(as_text=True))
self.assertEqual(get_response.status_code, status.HTTP_200_OK)
self.assertEqual(get_response_data['message'], new_message_message)
self.assertEqual(get_response_data['category']['name'],
new_message_category)
second_post_response = self.create_message(new_message_message, 15,
new_message_category)
self.assertEqual(second_post_response.status_code,
status.HTTP_400_BAD_REQUEST)
self.assertEqual(Message.query.count(), 1)
前面的代码向 InitialTests 类添加了许多方法。create_message 方法接收新消息所需的 message、duration 和 category(类别名称)作为参数。该方法构建 URL 和数据字典以组成并发送 HTTP POST 方法,创建新消息,并返回此请求生成的响应。许多测试方法将调用 create_message 方法来创建消息,然后向 API 组成并发送其他 HTTP 请求。
该类声明了以下方法,其名称以 test_ 前缀开头:
-
test_create_and_retrieve_message:测试我们是否可以创建一个新的Message并检索它。 -
test_create_duplicated_message:测试唯一约束是否使我们无法创建具有相同消息的两个消息。当我们第二次使用重复的消息组成并发送 HTTPPOST请求时,我们必须收到HTTP 400 Bad Request状态代码(status.HTTP_400_BAD_REQUEST),并且从数据库检索到的Message对象的总数必须是1。
打开现有的 api/tests/test_views.py 文件,在 InitialTests 类的最后一条语句之后插入以下行。示例代码文件包含在 restful_python_chapter_08_02 文件夹中:
def test_retrieve_messages_list(self):
"""
Ensure we can retrieve the messages paginated list
"""
create_user_response = self.create_user(self.test_user_name,
self.test_user_password)
self.assertEqual(create_user_response.status_code,
status.HTTP_201_CREATED)
new_message_message_1 = 'Welcome to the IoT world'
new_message_category_1 = 'Information'
post_response = self.create_message(new_message_message_1, 15,
new_message_category_1)
self.assertEqual(post_response.status_code, status.HTTP_201_CREATED)
self.assertEqual(Message.query.count(), 1)
new_message_message_2 = 'Initialization of the board failed'
new_message_category_2 = 'Error'
post_response = self.create_message(new_message_message_2, 10,
new_message_category_2)
self.assertEqual(post_response.status_code, status.HTTP_201_CREATED)
self.assertEqual(Message.query.count(), 2)
get_first_page_url = url_for('api.messagelistresource', _external=True)
get_first_page_response = self.test_client.get(
get_first_page_url,
headers=self.get_authentication_headers(self.test_user_name,
self.test_user_password))
get_first_page_response_data =
json.loads(get_first_page_response.get_data(as_text=True))
self.assertEqual(get_first_page_response.status_code, status.HTTP_200_OK)
self.assertEqual(get_first_page_response_data['count'], 2)
self.assertIsNone(get_first_page_response_data['previous'])
self.assertIsNone(get_first_page_response_data['next'])
self.assertIsNotNone(get_first_page_response_data['results'])
self.assertEqual(len(get_first_page_response_data['results']), 2)
self.assertEqual(get_first_page_response_data['results'][0]['message'],
new_message_message_1)
self.assertEqual(get_first_page_response_data['results'][1]['message'],
new_message_message_2)
get_second_page_url = url_for('api.messagelistresource', page=2)
get_second_page_response = self.test_client.get(
get_second_page_url,
headers=self.get_authentication_headers(self.test_user_name,
self.test_user_password))
get_second_page_response_data =
json.loads(get_second_page_response.get_data(as_text=True))
self.assertEqual(get_second_page_response.status_code,
status.HTTP_200_OK)
self.assertIsNotNone(get_second_page_response_data['previous'])
self.assertEqual(get_second_page_response_data['previous'],
url_for('api.messagelistresource', page=1))
self.assertIsNone(get_second_page_response_data['next'])
self.assertIsNotNone(get_second_page_response_data['results'])
self.assertEqual(len(get_second_page_response_data['results']), 0)
之前的代码向 InitialTests 类添加了 test_retrieve_messages_list 方法。该方法测试我们是否可以检索分页的消息列表。首先,该方法创建两条消息,然后确保检索到的列表包含第一页中的两条创建的消息。此外,该方法确保第二页不包含任何消息,并且上一页的值包含第一页的 URL。
打开现有的 api/tests/test_views.py 文件,在 InitialTests 类的最后一条语句之后插入以下行。示例代码文件包含在 restful_python_chapter_08_02 文件夹中:
def test_update_message(self):
"""
Ensure we can update a single field for an existing message
"""
create_user_response = self.create_user(self.test_user_name,
self.test_user_password)
self.assertEqual(create_user_response.status_code,
status.HTTP_201_CREATED)
new_message_message_1 = 'Welcome to the IoT world'
new_message_category_1 = 'Information'
post_response = self.create_message(new_message_message_1, 30,
new_message_category_1)
self.assertEqual(post_response.status_code, status.HTTP_201_CREATED)
self.assertEqual(Message.query.count(), 1)
post_response_data = json.loads(post_response.get_data(as_text=True))
new_message_url = post_response_data['url']
new_printed_times = 1
new_printed_once = True
data = {'printed_times': new_printed_times, 'printed_once':
new_printed_once}
patch_response = self.test_client.patch(
new_message_url,
headers=self.get_authentication_headers(self.test_user_name,
self.test_user_password),
data=json.dumps(data))
self.assertEqual(patch_response.status_code, status.HTTP_200_OK)
get_response = self.test_client.get(
new_message_url,
headers=self.get_authentication_headers(self.test_user_name,
self.test_user_password))
get_response_data = json.loads(get_response.get_data(as_text=True))
self.assertEqual(get_response.status_code, status.HTTP_200_OK)
self.assertEqual(get_response_data['printed_times'], new_printed_times)
self.assertEqual(get_response_data['printed_once'], new_printed_once)
def test_create_and_retrieve_user(self):
"""
Ensure we can create a new User and then retrieve it
"""
new_user_name = self.test_user_name
new_user_password = self.test_user_password
post_response = self.create_user(new_user_name, new_user_password)
self.assertEqual(post_response.status_code, status.HTTP_201_CREATED)
self.assertEqual(User.query.count(), 1)
post_response_data = json.loads(post_response.get_data(as_text=True))
self.assertEqual(post_response_data['name'], new_user_name)
new_user_url = post_response_data['url']
get_response = self.test_client.get(
new_user_url,
headers=self.get_authentication_headers(self.test_user_name,
self.test_user_password))
get_response_data = json.loads(get_response.get_data(as_text=True))
self.assertEqual(get_response.status_code, status.HTTP_200_OK)
self.assertEqual(get_response_data['name'], new_user_name)
-
之前的代码向
InitialTests类添加了以下两个方法:test_update_message- 测试我们是否可以更新一条消息的多个字段,具体来说,是printed_times和printed_once字段的值。代码确保这两个字段都已更新。 -
test_create_and_retrieve_user:测试我们是否可以创建一个新的User并检索它。
我们仅编写了一些与消息相关的测试和与用户相关的一个测试,以改进测试覆盖率并注意对测试覆盖率报告的影响。
现在,在同一个虚拟环境中运行以下命令:
nose2 -v --with-coverage
以下行显示了示例输出:
test_create_and_retrieve_category (test_views.InitialTests) ... ok
test_create_and_retrieve_message (test_views.InitialTests) ... ok
test_create_and_retrieve_user (test_views.InitialTests) ... ok
test_create_duplicated_category (test_views.InitialTests) ... ok
test_create_duplicated_message (test_views.InitialTests) ... ok
test_request_without_authentication (test_views.InitialTests) ... ok
test_retrieve_categories_list (test_views.InitialTests) ... ok
test_retrieve_messages_list (test_views.InitialTests) ... ok
test_update_category (test_views.InitialTests) ... ok
test_update_message (test_views.InitialTests) ... ok
------------------------------------------------------------------
Ran 10 tests in 25.938s
OK
----------- coverage: platform win32, python 3.5.2-final-0 -------
Name Stmts Miss Cover
-----------------------------------------
app.py 9 0 100%
config.py 11 11 0%
helpers.py 23 1 96%
migrate.py 9 9 0%
models.py 101 11 89%
run.py 4 4 0%
status.py 56 5 91%
test_config.py 16 0 100%
tests\test_views.py 203 0 100%
views.py 204 66 68%
-----------------------------------------
TOTAL 636 107 83%
输出提供了详细信息,表明测试运行器执行了10个测试,并且所有测试都通过了。由coverage包提供的测试代码覆盖率测量报告将views.py模块的Cover百分比从上一次运行的47%提高到68%。此外,由于我们编写了使用分页的测试,helpers.py模块的百分比从22%增加到96%。因此,我们编写的新附加测试在多个模块中执行了额外的代码,因此覆盖率报告有所影响。
小贴士
我们刚刚创建了一些单元测试来了解我们如何编写它们。然而,当然,编写更多的测试以提供对 API 中包含的所有功能和执行场景的适当覆盖是必要的。
理解部署和可扩展性的策略
Flask 是一个用于 Web 的轻量级微框架。然而,正如 Django 所发生的那样,与 Flask 和 Flask-RESTful 相关的一个最大的缺点是每个 HTTP 请求都是阻塞的。因此,每当 Flask 服务器收到一个 HTTP 请求时,它不会开始处理队列中的任何其他 HTTP 请求,直到服务器为它收到的第一个 HTTP 请求发送响应。
我们使用 Flask 开发了一个 RESTful Web 服务。这类 Web 服务的关键优势是它们是无状态的,也就是说,它们不应该在任何服务器上保持客户端状态。我们的 API 是使用 Flask 和 Flask RESTful 的一个很好的无状态 RESTful Web 服务的例子。因此,我们可以让 API 在尽可能多的服务器上运行,以实现我们的可扩展性目标。显然,我们必须考虑到我们很容易将数据库服务器变成我们的可扩展性瓶颈。
小贴士
现在,我们有许多基于云的替代方案来部署使用 Flask 和 Flask-RESTful 的 RESTful Web 服务,并使其具有极高的可扩展性。
在部署我们 API 的第一个版本之前,我们总是必须确保我们分析了 API 和数据库。确保生成的查询在底层数据库上正确运行,以及最常用的查询不会最终导致顺序扫描,这一点非常重要。通常需要在数据库中的表上添加适当的索引。
我们一直在使用基本的 HTTP 身份验证。我们可以通过基于令牌的身份验证来改进它。我们必须确保 API 在生产环境中运行在 HTTPS 下。此外,我们必须确保我们在api/config.py文件中更改以下行:
DEBUG = True
我们必须在生产环境中始终关闭调试模式,因此我们必须将前面的行替换为以下一行:
DEBUG = False
小贴士
使用不同的配置文件进行生产是非常方便的。然而,另一种方法正变得越来越流行,尤其是在云原生应用程序中,那就是将配置存储在环境中。如果我们想部署云原生 RESTful Web 服务并遵循十二因素应用程序中建立的指南,我们应该将配置存储在环境中。
每个平台都包含了详细的部署我们的应用程序的说明。所有这些都需要我们生成一个 requirements.txt 文件,其中列出了应用程序及其版本的依赖项。这样,平台就能够安装文件中列出的所有必要依赖项。
运行以下 pip freeze 命令以生成 requirements.txt 文件。
pip freeze > requirements.txt
以下行显示了生成的示例 requirements.txt 文件的内容。然而,请注意,许多包的版本号更新很快,你可能会在配置中看到不同的版本:
alembic==0.8.8
aniso8601==1.1.0
click==6.6
cov-core==1.15.0
coverage==4.2
Flask==0.11.1
Flask-HTTPAuth==3.2.1
flask-marshmallow==0.7.0
Flask-Migrate==2.0.0
Flask-RESTful==0.3.5
Flask-Script==2.0.5
Flask-SQLAlchemy==2.1
itsdangerous==0.24
Jinja2==2.8
Mako==1.0.4
MarkupSafe==0.23
marshmallow==2.10.2
marshmallow-sqlalchemy==0.10.0
nose2==0.6.5
passlib==1.6.5
psycopg2==2.6.2
python-dateutil==2.5.3
python-editor==1.0.1
pytz==2016.6.1
six==1.10.0
SQLAlchemy==1.0.15
Werkzeug==0.11.11
测试你的知识
-
默认情况下,
nose2会查找以下前缀开头的模块:-
test -
run -
unittest
-
-
默认情况下,
nose2从以下类的所有子类中加载测试。-
unittest.Test -
unittest.TestCase -
unittest.RunTest
-
-
unittest.TestCase子类中的setUp方法:-
在每个测试方法运行之前执行。
-
在所有测试开始执行之前只执行一次。
-
仅在所有测试执行完毕后执行一次。
-
-
unittest.TestCase子类中的tearDown方法:-
在每个测试方法运行之后执行。
-
在每个测试方法运行之前执行。
-
仅在测试方法失败后执行。
-
-
如果我们在
unittest.TestCase的子类中声明一个get_accept_content_type_headers方法,默认情况下,nose2:-
将此方法作为测试加载。
-
将此方法作为每个测试的
setUp方法加载。 -
不会将此方法作为测试加载。
-
摘要
在本章中,我们设置了测试环境。我们安装了 nose2 以便于发现和执行单元测试,并创建了一个新的数据库用于测试。我们编写了一轮单元测试,测量了测试覆盖率,然后编写了额外的单元测试以提高测试覆盖率。最后,我们了解了关于部署和可扩展性的许多考虑因素。
现在我们已经使用 Flask 结合 Flask RESTful 构建了一个复杂的 API,并且对其进行了测试,接下来我们将转向另一个流行的 Python Web 框架,Tornado,这也是我们将在下一章中讨论的内容。
第九章:使用 Tornado 开发 RESTful API
在本章中,我们将使用 Tornado 创建 RESTful 网络 API,并开始使用这个轻量级网络框架。我们将涵盖以下主题:
-
设计一个与慢速传感器和执行器交互的 RESTful API
-
理解每个
HTTP方法执行的任务 -
使用 Tornado 设置虚拟环境
-
声明响应的状态码
-
创建表示无人机的类
-
编写请求处理器
-
将 URL 模式映射到请求处理器
-
向 Tornado API 发送 HTTP 请求
-
使用命令行工具 - curl 和 HTTPie
-
使用 GUI 工具 - Postman 以及其他工具
设计一个与慢速传感器和执行器交互的 RESTful API
假设我们必须创建一个 RESTful API 来控制无人机,也称为无人驾驶飞行器(UAV)。无人机是一个物联网设备,与许多传感器和执行器交互,包括与发动机、螺旋桨和伺服电机连接的数字电子速度控制器。
物联网设备资源有限,因此我们必须使用轻量级网络框架。我们的 API 不需要与数据库交互。我们不需要像 Django 这样的重型网络框架,我们希望能够在不阻塞 Web 服务器的情况下处理许多请求。我们需要 Web 服务器为我们提供良好的可扩展性,同时消耗有限的资源。因此,我们的选择是使用 Tornado,这是 FriendFeed Web 服务器的开源版本。
物联网设备能够运行 Python 3.5、Tornado 以及其他 Python 包。Tornado 是一个 Python 网络框架和异步网络库,由于其非阻塞网络 I/O,提供了出色的可扩展性。此外,Tornado 将使我们能够轻松快速地构建轻量级的 RESTful API。
我们选择 Tornado 是因为它比 Django 更轻量级,它使我们能够轻松创建一个利用非阻塞网络 I/O 的 API。我们不需要使用 ORM,并希望尽快在物联网设备上运行 RESTful API,以便所有团队都能与之交互。
我们将交互一个库,允许我们在全局解释器锁(GIL)之外执行与传感器和执行器交互的慢速 I/O 操作。因此,当请求需要执行这些慢速 I/O 操作之一时,我们将利用 Tornado 的非阻塞特性。在我们的 API 第一个版本中,我们将使用同步执行,因此,当我们的 API 的 HTTP 请求需要运行慢速 I/O 操作时,我们将阻塞请求处理队列,直到传感器或执行器的慢速 I/O 操作提供响应。我们将使用同步执行执行 I/O 操作,并且 Tornado 不会继续处理其他传入的 HTTP 请求,直到向 HTTP 请求发送响应。
然后,我们将创建我们 API 的第二个版本,该版本将利用 Tornado 中包含的非阻塞特性,结合异步操作。在第二个版本中,当我们的 API 的 HTTP 请求需要运行慢速 I/O 操作时,我们不会阻塞请求处理队列,直到慢速 I/O 操作(与传感器或执行器)提供响应。我们将以异步执行执行 I/O 操作,Tornado 将继续处理其他传入的 HTTP 请求。
小贴士
我们将保持示例简单,并且不会使用库与传感器和执行器交互。我们只需打印出这些传感器和执行器将要执行的操作信息。然而,在我们的 API 第二个版本中,我们将编写代码以进行异步调用,以便理解 Tornado 的非阻塞特性的优势。我们将使用简化的传感器和执行器集——请记住,无人机通常有更多的传感器和执行器。我们的目标是学习如何使用 Tornado 构建 RESTful API;我们不想成为构建无人机的专家。
以下每个传感器和执行器都将成为我们 RESTful API 中的一个资源:
-
六旋翼飞行器,即六叶旋翼直升机
-
高空计(高度传感器)
-
蓝色LED(发光二极管)
-
白色 LED
以下表格显示了我们的 API 第一个版本必须支持的 HTTP 动词、作用域和语义。每个方法由一个 HTTP 动词和一个作用域组成,并且所有方法对所有传感器和执行器都有一个明确的含义。在我们的 API 中,每个传感器或执行器都有自己的唯一 URL:
| HTTP 动词 | 作用域 | 语义 |
|---|---|---|
GET |
六旋翼飞行器 | 获取当前六旋翼飞行器的电机速度(RPM)及其状态(开启或关闭) |
PATCH |
六旋翼飞行器 | 设置当前六旋翼飞行器的电机速度(RPM) |
GET |
LED | 获取单个 LED 的亮度级别 |
PATCH |
LED | 更新单个 LED 的亮度级别 |
GET |
高空计 | 获取当前高度(英尺) |
理解每个 HTTP 方法执行的任务
假设http://localhost:8888/hexacopters/1是标识我们无人机六旋翼飞行器的 URL。
我们必须使用以下 HTTP 动词(PATCH)和请求 URL(http://localhost:8888/hexacopters/1)来组合并发送一个 HTTP 请求,以设置六旋翼飞行器的电机速度(RPM)及其状态。此外,我们必须提供 JSON 键值对,包含必要的字段名和值,以指定所需的速度。作为请求的结果,服务器将验证提供的字段值,确保它是一个有效的速度,并以异步执行调用必要的操作来调整速度。在设置六旋翼飞行器的速度后,服务器将返回200 OK状态码和一个 JSON 体,其中最近更新的六旋翼飞行器值序列化为 JSON:
PATCH http://localhost:8888/hexacopters/1
我们必须使用以下 HTTP 动词 (GET) 和请求 URL (http://localhost:8888/hexacopter/1) 编写并发送一个 HTTP 请求,以检索六旋翼飞行器的当前值。服务器将以异步执行的方式调用必要的操作来检索六旋翼飞行器的状态和速度。请求的结果是,服务器将返回一个 200 OK 状态码和一个包含序列化键值对的 JSON 主体,这些键值对指定了六旋翼飞行器的状态和速度。如果指定的数字不是 1,服务器将仅返回一个 404 Not Found 状态:
GET http://localhost:8888/hexacopters/1
我们必须使用以下 HTTP 动词 (PATCH) 和请求 URL (http://localhost:8888/led/{id}) 编写并发送一个 HTTP 请求,以设置 ID 与 {id} 位置指定的数值相匹配的特定 LED 的亮度级别。例如,如果我们使用请求 URL http://localhost:8888/led/1,服务器将为 ID 与 1 匹配的 LED 设置亮度级别。此外,我们必须提供包含必要字段名称和值的 JSON 键值对,以指定所需的亮度级别。请求的结果是,服务器将验证提供的字段值,确保它是一个有效的亮度级别,并以异步执行的方式调用必要的操作来调整亮度级别。在设置 LED 的亮度级别后,服务器将返回一个 200 OK 状态码和一个包含最近更新的 LED 值序列化为 JSON 的 JSON 主体:
PATCH http://localhost:8888/led/{id}
我们必须使用以下 HTTP 动词 (GET) 和请求 URL (http://localhost:8888/led/{id}) 编写并发送一个 HTTP 请求,以检索 ID 与 {id} 位置指定的数值相匹配的 LED 的当前值。例如,如果我们使用请求 URL http://localhost:8888/led/1,服务器将检索 ID 与 1 匹配的 LED。服务器将以异步执行的方式调用必要的操作来检索 LED 的值。请求的结果是,服务器将返回一个 200 OK 状态码和一个包含序列化键值对的 JSON 主体,这些键值对指定了 LED 的值。如果没有 LED 与指定的 ID 匹配,服务器将仅返回一个 404 Not Found 状态:
GET http://localhost:8888/led/{id}
我们必须使用以下 HTTP 动词 (GET) 和请求 URL (http://localhost:8888/altimeter/1) 编写并发送一个 HTTP 请求,以检索高度计的当前值。服务器将以异步执行的方式调用必要的操作来检索高度计的值。请求的结果是,服务器将返回一个 200 OK 状态码和一个包含序列化键值对的 JSON 主体,这些键值对指定了高度计的值。如果指定的数字不是 1,服务器将仅返回一个 404 Not Found 状态:
GET http://localhost:8888/altimeter/1
使用 Tornado 设置虚拟环境
在第一章《使用 Django 开发 RESTful API》中,我们了解到,在本书中,我们将使用 Python 3.3 中引入并 Python 3.4 中改进的轻量级虚拟环境。现在,我们将遵循多个步骤创建一个新的轻量级虚拟环境以使用 Tornado。强烈建议阅读第一章《使用 Django 开发 RESTful API》,以防你对 Python 中的轻量级虚拟环境没有经验。该章节包含了我们将遵循的步骤的所有详细解释。
首先,我们必须选择我们的虚拟环境的目标文件夹或目录。以下是我们将在示例中使用的路径,用于 macOS 和 Linux。虚拟环境的目标文件夹将是我们主目录中的PythonREST/Tornado01文件夹。例如,如果我们的 macOS 或 Linux 中的主目录是/Users/gaston,虚拟环境将创建在/Users/gaston/PythonREST/Tornado01中。您可以在每个命令中将指定的路径替换为您想要的路径:
~/PythonREST/Tornado01
在示例中,我们将使用以下路径,用于 Windows。虚拟环境的目标文件夹将是我们用户配置文件文件夹中的PythonREST\Tornado01文件夹。例如,如果我们的用户配置文件文件夹是C:\Users\Gaston,虚拟环境将创建在C:\Users\gaston\PythonREST\Tornado01中。您可以在每个命令中将指定的路径替换为您想要的路径:
%USERPROFILE%\PythonREST\Tornado01
在 macOS 或 Linux 中打开一个终端并执行以下命令以创建虚拟环境:
python3 -m venv ~/PythonREST/Tornado01
在 Windows 中,执行以下命令以创建虚拟环境:
python -m venv %USERPROFILE%\PythonREST\Tornado01
上述命令不会产生任何输出。现在我们已经创建了一个虚拟环境,我们将运行一个特定平台的脚本以激活它。激活虚拟环境后,我们将安装只在此虚拟环境中可用的包。
如果您的终端配置为在 macOS 或 Linux 中使用bash shell,请运行以下命令以激活虚拟环境。该命令也适用于zsh shell:
source ~/PythonREST/Torando01/bin/activate
如果您的终端配置为使用csh或tcsh shell,请运行以下命令以激活虚拟环境:
source ~/PythonREST/Torando01/bin/activate.csh
如果您的终端配置为使用fish shell,请运行以下命令以激活虚拟环境:
source ~/PythonREST/Tornado01/bin/activate.fish
在 Windows 中,您可以在命令提示符中运行批处理文件或在 Windows PowerShell 中运行脚本以激活虚拟环境。如果您更喜欢命令提示符,请在 Windows 命令行中运行以下命令以激活虚拟环境:
%USERPROFILE%\PythonREST\Tornado01\Scripts\activate.bat
如果您更喜欢 Windows PowerShell,启动它并运行以下命令以激活虚拟环境。但是请注意,您需要在 Windows PowerShell 中启用脚本执行才能运行脚本:
cd $env:USERPROFILE
PythonREST\Tornado01\Scripts\Activate.ps1
在激活虚拟环境后,命令提示符将显示括号内的虚拟环境根文件夹名称作为默认提示的前缀,以提醒我们我们正在虚拟环境中工作。在这种情况下,我们将看到(Tornado01)作为命令提示符的前缀,因为激活的虚拟环境的根文件夹是 Tornado01。
我们已经创建并激活了虚拟环境。现在是时候运行许多命令了,这些命令对 macOS、Linux 或 Windows 都是一样的。现在,我们必须运行以下命令来使用 pip 安装 Tornado:
pip install tornado
输出的最后几行将指示所有成功安装的包,包括 tornado:
Collecting tornado
Downloading tornado-4.4.1.tar.gz (456kB)
Installing collected packages: tornado
Running setup.py install for tornado
Successfully installed tornado-4.4.1
声明响应的状态码
Tornado 允许我们生成包含在 http.HTTPStatus 字典中的任何状态码的响应。我们可能会使用这个字典来返回易于理解的状态码描述,例如在从 http 模块导入 HTTPStatus 字典后使用 HTTPStatus.OK 和 HTTPStatus.NOT_FOUND。这些名称易于理解,但它们的描述中不包含状态码数字。
在本书中,我们已经使用了许多不同的框架和微框架,因此,我们将从包含在 Django REST Framework 中的 status.py 文件中借用声明与 HTTP 状态码相关的非常有用的函数和变量的代码,即我们在第一章中使用过的框架。使用这些变量作为 HTTP 状态码的主要优点是它们的名称既包含数字又包含描述。当我们阅读代码时,我们将理解状态码数字及其含义。例如,我们不会使用 HTTPStatus.OK,而是使用 status.HTTP_200_OK。
在最近创建的虚拟环境根目录下创建一个新的 status.py 文件。以下行展示了在 status.py 文件中声明带有描述性 HTTP 状态码的函数和变量的代码,这些代码是从 rest_framework.status 模块借用的。我们不希望重新发明轮子,该模块提供了我们在基于 Tornado 的 API 中处理 HTTP 状态码所需的一切。示例代码文件包含在 restful_python_chapter_09_01 文件夹中:
def is_informational(code):
return code >= 100 and code <= 199
def is_success(code):
return code >= 200 and code <= 299
def is_redirect(code):
return code >= 300 and code <= 399
def is_client_error(code):
return code >= 400 and code <= 499
def is_server_error(code):
return code >= 500 and code <= 599
HTTP_100_CONTINUE = 100
HTTP_101_SWITCHING_PROTOCOLS = 101
HTTP_200_OK = 200
HTTP_201_CREATED = 201
HTTP_202_ACCEPTED = 202
HTTP_203_NON_AUTHORITATIVE_INFORMATION = 203
HTTP_204_NO_CONTENT = 204
HTTP_205_RESET_CONTENT = 205
HTTP_206_PARTIAL_CONTENT = 206
HTTP_300_MULTIPLE_CHOICES = 300
HTTP_301_MOVED_PERMANENTLY = 301
HTTP_302_FOUND = 302
HTTP_303_SEE_OTHER = 303
HTTP_304_NOT_MODIFIED = 304
HTTP_305_USE_PROXY = 305
HTTP_306_RESERVED = 306
HTTP_307_TEMPORARY_REDIRECT = 307
HTTP_400_BAD_REQUEST = 400
HTTP_401_UNAUTHORIZED = 401
HTTP_402_PAYMENT_REQUIRED = 402
HTTP_403_FORBIDDEN = 403
HTTP_404_NOT_FOUND = 404
HTTP_405_METHOD_NOT_ALLOWED = 405
HTTP_406_NOT_ACCEPTABLE = 406
HTTP_407_PROXY_AUTHENTICATION_REQUIRED = 407
HTTP_408_REQUEST_TIMEOUT = 408
HTTP_409_CONFLICT = 409
HTTP_410_GONE = 410
HTTP_411_LENGTH_REQUIRED = 411
HTTP_412_PRECONDITION_FAILED = 412
HTTP_413_REQUEST_ENTITY_TOO_LARGE = 413
HTTP_414_REQUEST_URI_TOO_LONG = 414
HTTP_415_UNSUPPORTED_MEDIA_TYPE = 415
HTTP_416_REQUESTED_RANGE_NOT_SATISFIABLE = 416
HTTP_417_EXPECTATION_FAILED = 417
HTTP_428_PRECONDITION_REQUIRED = 428
HTTP_429_TOO_MANY_REQUESTS = 429
HTTP_431_REQUEST_HEADER_FIELDS_TOO_LARGE = 431
HTTP_451_UNAVAILABLE_FOR_LEGAL_REASONS = 451
HTTP_500_INTERNAL_SERVER_ERROR = 500
HTTP_501_NOT_IMPLEMENTED = 501
HTTP_502_BAD_GATEWAY = 502
HTTP_503_SERVICE_UNAVAILABLE = 503
HTTP_504_GATEWAY_TIMEOUT = 504
HTTP_505_HTTP_VERSION_NOT_SUPPORTED = 505
HTTP_511_NETWORK_AUTHENTICATION_REQUIRED = 511
代码声明了五个函数,这些函数接收代码参数中的 HTTP 状态码,并确定状态码属于以下哪个类别:信息性、成功、重定向以及客户端错误或服务器错误类别。当我们需要返回特定的状态码时,我们将使用之前的变量。例如,如果我们需要返回 404 Not Found 状态码,我们将返回 status.HTTP_404_NOT_FOUND,而不是仅仅 404 或 HTTPStatus.NOT_FOUND。
创建表示无人机的类
我们将创建尽可能多的类来表示无人机不同组件。在现实生活中的例子中,这些类将与与传感器和执行器交互的库进行交互。为了使我们的示例简单,我们将调用 time.sleep 来模拟设置或从传感器和执行器设置或获取值所需的时间。
首先,我们将创建一个 Hexacopter 类,我们将使用它来表示六旋翼机,以及一个 HexacopterStatus 类,我们将使用它来存储六旋翼机的状态数据。创建一个新的 drone.py 文件。以下行显示了我们将创建的类所需的全部导入,以及 drone.py 文件中声明 Hexacopter 和 HexacopterStatus 类的代码。示例的代码文件包含在 restful_python_chapter_09_01 文件夹中:
from random import randint
from time import sleep
class HexacopterStatus:
def __init__(self, motor_speed, turned_on):
self.motor_speed = motor_speed
self.turned_on = turned_on
class Hexacopter:
MIN_SPEED = 0
MAX_SPEED = 1000
def __init__(self):
self.motor_speed = self.__class__.MIN_SPEED
self.turned_on = False
def get_motor_speed(self):
return self.motor_speed
def set_motor_speed(self, motor_speed):
if motor_speed < self.__class__.MIN_SPEED:
raise ValueError('The minimum speed is {0}'.format(self.__class__.MIN_SPEED))
if motor_speed > self.__class__.MAX_SPEED:
raise ValueError('The maximum speed is {0}'.format(self.__class__.MAX_SPEED))
self.motor_speed = motor_speed
self.turned_on = (self.motor_speed is not 0)
sleep(2)
return HexacopterStatus(self.get_motor_speed(), self.is_turned_on())
def is_turned_on(self):
return self.turned_on
def get_hexacopter_status(self):
sleep(3)
return HexacopterStatus(self.get_motor_speed(), self.is_turned_on())
HexacopterStatus 类仅声明了一个构造函数,即 __init__ 方法。该方法接收许多参数,并使用它们以相同的名称初始化属性:motor_speed 和 turned_on。
Hexacopter 类声明了两个类属性,指定最小和最大速度值:MIN_SPEED 和 MAX_SPEED。构造函数,即 __init__ 方法,使用 MIN_SPEED 值初始化 motor_speed 属性,并将 turned_on 属性设置为 False。
get_motor_speed 方法返回 motor_speed 属性的值。set_motor_speed 方法检查 motor_speed 参数的值是否在有效范围内。如果验证失败,该方法将抛出 ValueError 异常。否则,该方法将使用接收到的值设置 motor_speed 属性的值,如果 motor_speed 大于 0,则将 turned_on 属性的值设置为 True。最后,该方法调用 sleep 来模拟获取六旋翼机状态需要两秒钟,然后返回一个使用 motor_speed 和 turned_on 属性值初始化的 HexacopterStatus 实例,这些值通过特定方法检索得到。
get_hexacopter_status 方法调用 sleep 来模拟获取六旋翼机状态需要三秒钟,然后返回一个使用 motor_speed 和 turned_on 属性值初始化的 HexacopterStatus 实例。
现在,我们将创建一个 LightEmittingDiode 类,我们将使用它来表示每个 LED。打开之前创建的 drone.py 文件,并添加以下行。示例的代码文件包含在 restful_python_chapter_09_01 文件夹中:
class LightEmittingDiode:
MIN_BRIGHTNESS_LEVEL = 0
MAX_BRIGHTNESS_LEVEL = 255
def __init__(self, identifier, description):
self.identifier = identifier
self.description = description
self.brightness_level = self.__class__.MIN_BRIGHTNESS_LEVEL
def get_brightness_level(self):
sleep(1)
return self.brightness_level
def set_brightness_level(self, brightness_level):
if brightness_level < self.__class__.MIN_BRIGHTNESS_LEVEL:
raise ValueError('The minimum brightness level is {0}'.format(self.__class__.MIN_BRIGHTNESS_LEVEL))
if brightness_level > self.__class__.MAX_BRIGHTNESS_LEVEL:
raise ValueError('The maximum brightness level is {0}'.format(self.__class__.MAX_BRIGHTNESS_LEVEL))
sleep(2)
self.brightness_level = brightness_level
LightEmittingDiode 类声明了两个类属性,指定最小和最大亮度级别值:MIN_BRIGHTNESS_LEVEL 和 MAX_BRIGHTNESS_LEVEL。构造函数,即 __init__ 方法,使用 MIN_BRIGHTNESS_LEVEL 初始化 brightness_level 属性,并使用具有相同名称的参数值初始化 id 和 description 属性。
get_brightness_level方法调用sleep来模拟,获取有线 LED 的亮度级别需要 1 秒钟,然后返回brightness_level属性的值。
set_brightness_level方法检查brightness_level参数的值是否在有效范围内。如果验证失败,该方法将引发ValueError异常。否则,该方法调用sleep来模拟设置新的亮度级别需要两秒钟,最后使用接收到的值设置brightness_level属性的值。
现在,我们将创建一个Altimeter类,我们将使用它来表示高度计。打开之前创建的drone.py文件,并添加以下几行。示例的代码文件包含在restful_python_chapter_09_01文件夹中:
class Altimeter:
def get_altitude(self):
sleep(1)
return randint(0, 3000)
Altimeter类声明了一个get_altitude方法,它调用sleep来模拟从高度计获取高度需要一秒钟,最后生成一个从 0 到3000(包含)的随机整数并返回它。
最后,我们将创建一个Drone类,我们将使用它来表示带有其传感器和执行器的无人机。打开之前创建的drone.py文件,并添加以下几行。示例的代码文件包含在restful_python_chapter_09_01文件夹中
class Drone:
def __init__(self):
self.hexacopter = Hexacopter()
self.altimeter = Altimeter()
self.blue_led = LightEmittingDiode(1, 'Blue LED')
self.white_led = LightEmittingDiode(2, 'White LED')
self.leds = {
self.blue_led.identifier: self.blue_led,
self.white_led.identifier: self.white_led
}
Drone类仅声明了一个构造函数,即__init__方法,它创建了代表无人机不同组件的先前声明的类的实例。leds属性保存了一个字典,其中每个LightEmittingDiode实例都有一个键值对,包含其 id 和实例。
编写请求处理器
在 tornado 中,RESTful API 的主要构建块是tornado.web.RequestHandler类的子类,即 Tornado 中 HTTP 请求处理器的基类。我们只需要创建这个类的子类,并声明每个支持的 HTTP 动词的方法。我们必须重写这些方法来处理 HTTP 请求。然后,我们必须将 URL 模式映射到代表 Tornado Web 应用的tornado.web.Application实例中的每个tornado.web.RequestHandler的子类。
首先,我们将创建一个HexacopterHandler类,我们将使用它来处理对六旋翼资源的需求。创建一个新的api.py文件。以下几行展示了我们将创建的类所需的全部导入,以及drone.py文件中声明HexacopterHandler类的代码。在新的api.py文件中输入以下几行。示例的代码文件包含在restful_python_chapter_09_01文件夹中:
import status
from datetime import date
from tornado import web, escape, ioloop, httpclient, gen
from drone import Altimeter, Drone, Hexacopter, LightEmittingDiode
drone = Drone()
class HexacopterHandler(web.RequestHandler):
SUPPORTED_METHODS = ("GET", "PATCH")
HEXACOPTER_ID = 1
def get(self, id):
if int(id) is not self.__class__.HEXACOPTER_ID:
self.set_status(status.HTTP_404_NOT_FOUND)
return
print("I've started retrieving hexacopter's status")
hexacopter_status = drone.hexacopter.get_hexacopter_status()
print("I've finished retrieving hexacopter's status")
response = {
'speed': hexacopter_status.motor_speed,
'turned_on': hexacopter_status.turned_on,
}
self.set_status(status.HTTP_200_OK)
self.write(response)
def patch(self, id):
if int(id) is not self.__class__.HEXACOPTER_ID:
self.set_status(status.HTTP_404_NOT_FOUND)
return
request_data = escape.json_decode(self.request.body)
if ('motor_speed' not in request_data.keys()) or \
(request_data['motor_speed'] is None):
self.set_status(status.HTTP_400_BAD_REQUEST)
return
try:
motor_speed = int(request_data['motor_speed'])
print("I've started setting the hexacopter's motor speed")
hexacopter_status = drone.hexacopter.set_motor_speed(motor_speed)
print("I've finished setting the hexacopter's motor speed")
response = {
'speed': hexacopter_status.motor_speed,
'turned_on': hexacopter_status.turned_on,
}
self.set_status(status.HTTP_200_OK)
self.write(response)
except ValueError as e:
print("I've failed setting the hexacopter's motor speed")
self.set_status(status.HTTP_400_BAD_REQUEST)
response = {
'error': e.args[0]
}
self.write(response)
HexacopterHandler类是tornado.web.RequestHandler的子类,并声明了以下两个方法,当具有相同名称的 HTTP 方法作为请求到达此 HTTP 处理器时将被调用:
-
get: 此方法通过id参数接收需要检索状态的六旋翼飞行器的id。如果接收到的id与HEXACOPTER_ID类属性的值不匹配,代码将调用self.set_status方法,并将status.HTTP_404_NOT_FOUND作为参数来设置响应的状态码为HTTP 404 Not Found。否则,代码将打印一条消息,表明它开始检索六旋翼飞行器的状态,并使用同步执行调用drone.hexacopter.get_hexacopter_status方法,并将结果保存到hexacopter_status变量中。然后,代码将写入一条消息,表明它已完成状态的检索,并生成一个包含'speed'和'turned_on'键及其值的response字典。最后,代码将调用self.set_status方法,并将status.HTTP_200_OK作为参数来设置响应的状态码为HTTP 200 OK,并调用self.write方法,将response字典作为参数。由于response是一个字典,Tornado 自动将块写入 JSON 格式,并将Content-Type头部的值设置为application/json。 -
patch: 此方法通过id参数接收需要更新或修补的六旋翼飞行器的id。正如之前解释的get方法中发生的情况,如果接收到的id与HEXACOPTER_ID类属性的值不匹配,代码将返回HTTP 404 Not Found。否则,代码将调用tornado.escape.json_decode方法,并将self.request.body作为参数来生成请求体 JSON 字符串的 Python 对象,并将生成的字典保存到request_data变量中。如果字典中不包含名为'motor_speed'的键,代码将返回HTTP 400 Bad Request状态码。如果存在该键,代码将打印一条消息,表明它开始设置六旋翼飞行器的速度,并使用同步执行调用drone.hexacopter.set_motor_speed方法,并将结果保存到hexacopter_status变量中。如果指定的电机速度值无效,将捕获ValueError异常,代码将返回HTTP 400 Bad Request状态码,并将验证错误消息作为响应体。否则,代码将写入一条消息,表明它已完成电机速度的设置,并生成一个包含'speed'和'turned_on'键及其值的response字典。最后,代码将调用self.set_status方法,并将status.HTTP_200_OK作为参数来设置响应的状态码为 HTTP 200 OK,并调用self.write方法,将response字典作为参数。由于response是一个字典,Tornado 自动将块写入 JSON 格式,并将Content-Type头部的值设置为application/json。
该类覆盖了SUPPORTED_METHODS类变量,使用一个元组表示该类仅支持GET和PATCH方法。这样,如果请求的处理程序请求的不是一个包含在SUPPORTED_METHODS元组中的方法,服务器将自动返回405 Method Not Allowed状态码。
现在,我们将创建一个LedHandler类,用于表示 LED 资源。打开之前创建的api.py文件,并添加以下行。示例的代码文件包含在restful_python_chapter_09_01文件夹中:
class LedHandler(web.RequestHandler):
SUPPORTED_METHODS = ("GET", "PATCH")
def get(self, id):
int_id = int(id)
if int_id not in drone.leds.keys():
self.set_status(status.HTTP_404_NOT_FOUND)
return
led = drone.leds[int_id]
print("I've started retrieving {0}'s status".format(led.description))
brightness_level = led.get_brightness_level()
print("I've finished retrieving {0}'s status".format(led.description))
response = {
'id': led.identifier,
'description': led.description,
'brightness_level': brightness_level
}
self.set_status(status.HTTP_200_OK)
self.write(response)
def patch(self, id):
int_id = int(id)
if int_id not in drone.leds.keys():
self.set_status(status.HTTP_404_NOT_FOUND)
return
led = drone.leds[int_id]
request_data = escape.json_decode(self.request.body)
if ('brightness_level' not in request_data.keys()) or \
(request_data['brightness_level'] is None):
self.set_status(status.HTTP_400_BAD_REQUEST)
return
try:
brightness_level = int(request_data['brightness_level'])
print("I've started setting the {0}'s brightness
level".format(led.description))
led.set_brightness_level(brightness_level)
print("I've finished setting the {0}'s brightness
level".format(led.description))
response = {
'id': led.identifier,
'description': led.description,
'brightness_level': brightness_level
}
self.set_status(status.HTTP_200_OK)
self.write(response)
except ValueError as e:
print("I've failed setting the {0}'s brightness
level".format(led.description))
self.set_status(status.HTTP_400_BAD_REQUEST)
response = {
'error': e.args[0]
}
self.write(response)
LedHandler类是tornado.web.RequestHandler的子类。该类覆盖了SUPPORTED_METHODS类变量,使用一个元组表示该类仅支持GET和PATCH方法。此外,该类声明了以下两个方法,当 HTTP 处理程序接收到具有相同名称的 HTTP 方法请求时,将调用这些方法:
-
get:此方法接收id参数中要检索状态的 LED 的id。如果接收到的 id 不是drone.leds字典的键之一,代码将调用self.set_status方法,并将status.HTTP_404_NOT_FOUND作为参数来设置响应的状态码为HTTP 404 Not Found。否则,代码检索与drone.leds字典中匹配 id 的键关联的值,并将检索到的LightEmittingDiode实例保存到led变量中。代码打印一条消息,表明它开始检索 LED 的亮度级别,然后以同步方式调用led.get_brightness_level方法,并将结果保存到brightness_level变量中。然后,代码打印一条消息,表明它已完成亮度级别的检索,并生成一个包含'id'、'description'和'brightness_level'键及其值的response字典。最后,代码调用self.set_status方法,并将status.HTTP_200_OK作为参数来设置响应的状态码为 HTTP 200 OK,并调用self.write方法,将response字典作为参数。由于response是一个字典,Tornado 会自动将块作为 JSON 写入,并将Content-Type头部的值设置为application/json。 -
patch方法:该方法接收需要更新或修补的 LED 的 id,作为id参数。与之前解释的get方法一样,如果接收到的 id 不匹配drone.leds字典中的任何键,则代码返回HTTP 404 Not Found。否则,代码使用self.request.body作为参数调用tornado.escape.json_decode方法,以生成请求体 JSON 字符串的 Python 对象,并将生成的字典保存到request_data变量中。如果字典中不包含名为'brightness_level'的键,则代码返回HTTP 400 Bad Request状态码。如果存在该键,代码将打印一条消息,表明它开始设置 LED 的亮度级别,包括 LED 的描述,调用drone.hexacopter.set_brightness_level方法进行同步执行。如果指定的brightness_level值无效,将捕获ValueError异常,并返回HTTP 400 Bad Request状态码以及验证错误消息作为响应体。否则,代码将写入一条消息,表明它已完成设置 LED 的亮度值,并生成一个包含'id'、'description'和'brightness_level'键及其值的response字典。最后,代码使用status.HTTP_200_OK作为参数调用self.set_status方法,将响应的状态码设置为 HTTP 200 OK,并使用response字典作为参数调用self.write方法。由于response是一个字典,Tornado 自动将块作为 JSON 写入,并将Content-Type标头的值设置为application/json。
现在,我们将创建一个 AltimeterHandler 类,我们将使用它来表示高度计资源。打开之前创建的 api.py 文件,并添加以下行。示例的代码文件包含在 restful_python_chapter_09_01 文件夹中:
class AltimeterHandler(web.RequestHandler):
SUPPORTED_METHODS = ("GET")
ALTIMETER_ID = 1
def get(self, id):
if int(id) is not self.__class__.ALTIMETER_ID:
self.set_status(status.HTTP_404_NOT_FOUND)
return
print("I've started retrieving the altitude")
altitude = drone.altimeter.get_altitude()
print("I've finished retrieving the altitude")
response = {
'altitude': altitude
}
self.set_status(status.HTTP_200_OK)
self.write(response)
AltimeterHandler 类是 tornado.web.RequestHandler 的子类。该类通过一个表示仅支持 GET 方法的元组覆盖了 SUPPORTED_METHODS 类变量。此外,该类声明了一个 get 方法,当以相同名称的 HTTP 方法作为请求到达此 HTTP 处理程序时,将调用该方法。
get 方法接收 id 参数中要检索高度计的 id。如果接收到的 id 与 ALTIMETER_ID 类属性的值不匹配,代码将调用 self.set_status 方法,并将 status.HTTP_404_NOT_FOUND 作为参数来设置响应的状态码为 HTTP 404 Not Found。否则,代码打印一条消息,表明它开始检索高度计的高度,调用 drone.hexacopter.get_altitude 方法进行同步执行,并将结果保存到 altitude 变量中。然后,代码写入一条消息,表明它已完成高度检索,并生成一个包含 'altitude' 键及其值的 response 字典。最后,代码调用 self.set_status 方法,并将 status.HTTP_200_OK 作为参数来设置响应的状态码为 HTTP 200 OK,并调用 self.write 方法,将 response 字典作为参数。由于 response 是一个字典,Tornado 自动将块作为 JSON 写入,并将 Content-Type 头的值设置为 application/json。
以下表格显示了我们要为每个 HTTP 动词和作用域组合执行的之前创建的 HTTP 处理器类的函数:
| HTTP verb | Scope | Class and method |
|---|---|---|
GET |
Hexacopter | HexacopterHandler.get |
PATCH |
Hexacopter | HexacopterHandler.patch |
GET |
LED | LedHandler.get |
PATCH |
LED | LedHandler.patch |
GET |
Altimeter | AltimeterHandler.get |
如果请求导致调用一个不支持 HTTP 方法的 HTTP 处理器类,Tornado 将返回一个带有 HTTP 405 Method Not Allowed 状态码的响应。
将 URL 模式映射到请求处理器
我们必须将 URL 模式映射到之前编写的 tornado.web.RequestHandler 的子类。以下行创建了应用程序的主要入口点,使用 API 的 URL 模式对其进行初始化,并开始监听请求。打开之前创建的 api.py 文件并添加以下行。示例的代码文件包含在 restful_python_chapter_09_01 文件夹中:
application = web.Application([
(r"/hexacopters/([0-9]+)", HexacopterHandler),
(r"/leds/([0-9]+)", LedHandler),
(r"/altimeters/([0-9]+)", AltimeterHandler),
], debug=True)
if __name__ == "__main__":
port = 8888
print("Listening at port {0}".format(port))
application.listen(port)
ioloop.IOLoop.instance().start()
之前的代码创建了一个名为 application 的 tornado.web.Application 实例,其中包含构成 Web 应用程序的请求处理器集合。代码将一个元组列表传递给 Application 构造函数。列表由一个正则表达式(regexp)和一个 tornado.web.RequestHandler 子类(request_class)组成。此外,代码将 debug 参数设置为 True 以启用调试。
main 方法调用 application.listen 方法,在指定的端口上为应用程序构建一个遵循定义规则的 HTTP 服务器。在这种情况下,代码将 8888 指定为端口,保存在 port 变量中,这是 Tornado HTTP 服务器的默认端口。然后,对 tornado.ioloop.IOLoop.instance().start() 的调用启动了通过之前的 application.listen 方法创建的服务器。
小贴士
与任何其他 Web 框架一样,你永远不应该在生产环境中启用调试。
向 Tornado API 发送 HTTP 请求
现在,我们可以运行 api.py 脚本,它启动 Tornado 的开发服务器,以便我们可以编写和发送 HTTP 请求到我们的不安全且简单的 Web API。执行以下命令:
python api.py
以下行显示了执行之前命令后的输出。Tornado HTTP 开发服务器正在端口 8888 上监听:
Listening at port 8888
使用之前的命令,我们将启动 Tornado HTTP 服务器,它将在每个接口上监听端口 8888。因此,如果我们想从连接到我们局域网的其它计算机或设备向我们的 API 发送 HTTP 请求,我们不需要任何额外的配置。
小贴士
如果你决定从连接到局域网的其它计算机或设备发送和发送 HTTP 请求,请记住你必须使用开发计算机分配的 IP 地址而不是 localhost。例如,如果计算机分配的 IPv4 IP 地址是 192.168.1.103,那么你应该使用 192.168.1.103:8888 而不是 localhost:8888。当然,你也可以使用主机名而不是 IP 地址。之前解释的配置非常重要,因为移动设备可能是我们 RESTful API 的消费者,我们总是希望在开发环境中测试使用我们 API 的应用程序。
Tornado HTTP 服务器正在本地主机 (127.0.0.1) 上运行,监听端口 8888,等待我们的 HTTP 请求。现在,我们将在我们开发计算机本地或从连接到我们的局域网的其它计算机或设备发送 HTTP 请求。
使用命令行工具——curl 和 httpie
我们将使用我们在 第一章 中介绍的命令行工具——使用 Django 开发 RESTful API、curl 和 HTTPie 来编写和发送 HTTP 请求。如果你还没有安装 HTTPie,请确保激活虚拟环境,然后在终端或命令提示符中运行以下命令来安装 HTTPie 包:
pip install --upgrade httpie
小贴士
如果你忘记了如何激活为我们这个示例创建的虚拟环境,请阅读本章的以下部分——使用 Django REST Framework 设置虚拟环境。
在 Windows 中打开 Cygwin 终端或在 macOS 或 Linux 中打开终端,并运行以下命令。我们将编写和发送一个 HTTP 请求来打开六旋翼飞行器并将其电机速度设置为 100 RPM:
http PATCH :8888/hexacopters/1 motor_speed=100
以下是对应的 curl 命令。非常重要的一点是使用 -H "Content-Type: application/json" 选项来指示 curl 将 -d 选项之后指定的数据作为 application/json 而不是默认的 application/x-www-form-urlencoded 发送:
curl -iX PATCH -H "Content-Type: application/json" -d '{"motor_speed":100}'
:8888/hexacopters/1
前面的命令将组合并发送以下 HTTP 请求,PATCH http://localhost:8888/hexacopters/1,附带以下 JSON 键值对:
{
"motor_speed": 100
}
请求指定了 /hexacopters/1,因此,Tornado 将遍历包含正则表达式和请求类的元组列表,并匹配 '/hexacopters/([0-9]+)'。Tornado 将创建 HexacopterHandler 类的一个实例,并使用 1 作为 id 参数的值来运行 HexacopterHandler.patch 方法。由于请求的 HTTP 动词是 PATCH,Tornado 调用 patch 方法。如果成功设置了六旋翼飞行器的速度,该方法将返回 HTTP 200 OK 状态码,并将速度和状态作为键值对序列化为 JSON 格式放在响应体中。以下几行显示了 HTTP 请求的一个示例响应:
HTTP/1.1 200 OK
Content-Length: 33
Content-Type: application/json; charset=UTF-8
Date: Thu, 08 Sep 2016 02:02:27 GMT
Server: TornadoServer/4.4.1
{
"speed": 100,
"turned_on": true
}
我们将组合并发送一个 HTTP 请求以检索六旋翼飞行器的状态和电机速度。回到 Windows 的 Cygwin 终端或 macOS 或 Linux 的终端,并运行以下命令:
http :8888/hexacopters/1
以下是对应的 curl 命令:
curl -iX GET -H :8888/hexacopters/1
前面的命令将组合并发送以下 HTTP 请求:GET http://localhost:8888/hexacopters/1。请求指定了 /hexacopters/1,因此,它将匹配 '/hexacopters/([0-9]+)' 并运行 HexacopterHandler.get 方法,其中 1 作为 id 参数的值。由于请求的 HTTP 动词是 GET,Tornado 调用 get 方法。该方法检索六旋翼飞行器的状态,并生成包含键值对的 JSON 响应。
以下几行显示了 HTTP 请求的一个示例响应。前几行显示了 HTTP 响应头,包括状态(200 OK)和 Content-type(application/json)。在 HTTP 响应头之后,我们可以在 JSON 响应中看到六旋翼飞行器的状态详情:
HTTP/1.1 200 OK
Content-Length: 33
Content-Type: application/json; charset=UTF-8
Date: Thu, 08 Sep 2016 02:26:00 GMT
Etag: "ff152383ca6ebe97e5a136166f433fbe7f9b4434"
Server: TornadoServer/4.4.1
{
"speed": 100,
"turned_on": true
}
在我们运行三个请求之后,我们将在运行 Tornado HTTP 服务器的窗口中看到以下几行。输出显示了执行描述代码何时开始设置或检索信息以及何时完成的打印语句的结果:
I've started setting the hexacopter's motor speed
I've finished setting the hexacopter's motor speed
I've started retrieving hexacopter's status
I've finished retrieving hexacopter's status
我们在请求处理类中编写的不同方法最终都会调用 time.sleep 来模拟与六旋翼无人机的操作需要一些时间。在这种情况下,我们的代码是以同步执行方式运行的,因此,每次我们编写和发送请求时,Tornado 服务器都会被阻塞,直到与六旋翼无人机的操作完成并且方法发送响应。我们将在稍后创建此 API 的新版本,该版本将使用异步执行,我们将了解 Tornado 非阻塞功能的优点。然而,首先,我们将了解 API 的同步版本是如何工作的。
以下图像显示了 macOS 上并排的两个终端窗口。左侧的终端窗口正在运行 Tornado HTTP 服务器,并显示处理 HTTP 请求的方法中打印的消息。右侧的终端窗口正在运行 http 命令以生成 HTTP 请求。在编写和发送 HTTP 请求时检查输出,使用类似的配置是一个好主意:

现在,我们将编写和发送一个 HTTP 请求以检索一个不存在的六旋翼无人机。请记住,我们只有一个六旋翼无人机。运行以下命令尝试检索具有无效 id 的六旋翼无人机的状态。我们必须确保实用程序将标题作为响应的一部分显示,以便查看返回的状态码:
http :8888/hexacopters/8
以下是对应的 curl 命令:
curl -iX GET :8888/hexacopters/8
之前的命令将组合并发送以下 HTTP 请求:GET http://localhost:8888/hexacopters/8。该请求与之前我们分析过的请求相同,只是 id 参数的数字不同。服务器将使用 8 作为 id 参数的值来运行 HexacopterHandler.get 方法。id 不等于 1,因此,代码将返回 HTTP 404 Not Found 状态码。以下行显示了 HTTP 请求的一个示例响应头:
HTTP/1.1 404 Not Found
Content-Length: 0
Content-Type: text/html; charset=UTF-8
Date: Thu, 08 Sep 2016 04:31:53 GMT
Server: TornadoServer/4.4.1
使用 GUI 工具 - Postman 及其他
到目前为止,我们一直在使用两个基于终端或命令行的工具来编写和发送 HTTP 请求到我们的 Tornado HTTP 服务器 - cURL 和 HTTPie。现在,我们将使用我们在编写和发送 HTTP 请求到 Django 开发服务器和 Flask 开发服务器时使用的 GUI 工具之一:Postman。
现在,我们将使用 Postman 中的 Builder 选项卡轻松地组合和发送 HTTP 请求到 localhost:8888,并使用此 GUI 工具测试 RESTful API。请记住,Postman 不支持 curl 类型的本地主机缩写,因此,我们无法在用 curl 和 HTTPie 编写请求时使用相同的缩写。
在输入请求 URL文本框左侧的下拉菜单中选择GET,并在右侧的文本框中输入localhost:8888/leds/1。现在,点击发送,Postman 将显示状态(200 OK)、请求处理所需的时间以及以 JSON 格式显示并带有语法高亮的响应体(美化视图)。
以下截图显示了 Postman 中 HTTP GET 请求的 JSON 响应体:

点击Body右侧的头部和Cookies以读取响应头部。以下截图显示了 Postman 为之前响应显示的响应头部布局。请注意,Postman 在响应右侧显示状态,并且不将其作为头部第一行,这与我们使用 cURL 和 HTTPie 工具时的情况不同:

现在,我们将使用 Postman 的Builder标签来编写并发送一个创建新消息的 HTTP 请求,具体来说,是一个 PATCH 请求。按照以下步骤操作:
-
在输入请求 URL文本框左侧的下拉菜单中选择PATCH,并在右侧的文本框中输入
localhost:8888/leds/1。 -
点击请求面板右侧的Body,以在Authorization和Headers右侧点击。
-
激活原始单选按钮,并在二进制单选按钮右侧的下拉菜单中选择
JSON (application/json)。Postman 将自动添加Content-type = application/json头信息,因此,你会注意到头部标签将被重命名为头部(1),这表明我们已为请求头部指定了一个键值对。 -
在Body标签下方的文本框中输入以下行:
{
"brightness_level": 128
}
以下截图显示了 Postman 中的请求体:

我们遵循了必要的步骤来创建一个带有 JSON 体、指定创建新游戏所需键值对的 HTTP PATCH请求。点击发送,Postman 将显示状态(200 OK)、请求处理所需的时间以及以 JSON 格式显示并带有语法高亮的最近添加的游戏响应体(美化视图)。以下截图显示了 Postman 中 HTTP POST 请求的 JSON 响应体。

Tornado HTTP 服务器正在监听所有接口的8888端口,因此,我们也可以使用能够从移动设备组成并发送 HTTP 请求的应用程序来与 RESTful API 一起工作。例如,我们可以在 iPad Pro 和 iPhone 等 iOS 设备上使用之前介绍的 iCurlHTTP 应用程序。在 Android 设备上,我们可以使用之前介绍的 HTTP Request 应用程序。
以下截图显示了使用 iCurlHTTP 应用程序组成并发送以下 HTTP 请求的结果——GET http://192.168.2.3:8888/altimeters/1。请记住,你必须在你的 LAN 和路由器中执行之前解释的配置,才能从连接到你的 LAN 的其他设备访问 Flask 开发服务器。在这种情况下,运行 Tornado HTTP 服务器的计算机分配的 IP 地址是192.168.2.3,因此,你必须将此 IP 替换为分配给你的开发计算机的 IP 地址:

测试你的知识
-
Tornado 中 RESTful API 的主要构建块是以下类的子类:
-
tornado.web.GenericHandler -
tornado.web.RequestHandler -
tornado.web.IncomingHTTPRequestHandler
-
-
如果我们只想支持
GET和PATCH方法,我们可以用以下哪个值覆盖SUPPORTED_METHODS类变量:-
("GET", "PATCH") -
{0: "GET", 1: "PATCH"} -
{"GET": True, "PATCH": True, "POST": False, "PUT": False}
-
-
tornado.Web.Application构造函数的元组列表由以下组成:-
一个正则表达式(
regexp)和一个tornado.web.RequestHandler子类(request_class)。 -
一个正则表达式(
regexp)和一个tornado.web.GenericHandler子类(request_class)。 -
一个正则表达式(
regexp)和一个tornado.web.IncomingHTTPRequestHandler子类(request_class)。
-
-
当我们在请求处理器中调用
self.write方法并将字典作为参数时,Tornado:-
自动将块写入 JSON 格式,但我们必须手动将
Content-Type头部的值设置为application/json。 -
需要我们使用
json.dumps方法并将Content-Type头部的值设置为application/json。 -
自动将块写入 JSON 格式,并将
Content-Type头部的值设置为application/json。
-
-
在请求处理器中调用
tornado.escape.json_decode方法,并将self.request.body作为参数:-
为请求体的 JSON 字符串生成 Python 对象,并返回生成的元组。
-
为请求体的 JSON 字符串生成 Python 对象,并返回生成的字典。
-
为请求体的 JSON 字符串生成 Python 对象,并返回生成的列表。
-
摘要
在本章中,我们设计了一个 RESTful API 来与慢速传感器和执行器交互。我们定义了 API 的要求,理解了每个 HTTP 方法执行的任务,并使用 Tornado 设置了一个虚拟环境。
我们创建了代表无人机的类,并编写了代码来模拟每个 HTTP 请求方法所需的慢速 I/O 操作,编写了代表请求处理器并处理不同 HTTP 请求的类,并配置了 URL 模式以将 URL 路由到请求处理器及其方法。
最后,我们启动了 Tornado 开发服务器,使用命令行工具来组合并发送 HTTP 请求到我们的 RESTful API,并分析了我们的代码中每个 HTTP 请求的处理方式。我们还使用 GUI 工具来组合并发送 HTTP 请求。
现在我们已经了解了 Tornado 的基本知识以创建 RESTful API,我们将利用 Tornado 的非阻塞特性结合异步操作在 API 的新版本中,这就是我们将在下一章中讨论的内容。
第十章. 使用 Tornado 处理异步代码、测试和部署 API
在本章中,我们将利用 Tornado 的非阻塞特性以及异步操作,为我们在上一章中构建的 API 创建一个新版本。我们将配置、编写和执行单元测试,并学习一些与部署相关的内容。我们将涵盖以下主题:
-
理解同步和异步执行
-
使用异步代码
-
重新设计代码以利用异步装饰器
-
将 URL 模式映射到异步和非阻塞请求处理器
-
向 Tornado 非阻塞 API 发送 HTTP 请求
-
设置单元测试
-
编写第一轮单元测试
-
使用
nose2运行单元测试并检查测试覆盖率 -
提高测试覆盖率
理解同步和异步执行
在我们当前的 API 版本中,每个 HTTP 请求都是阻塞的,就像 Django 和 Flask 一样。因此,每当 Tornado HTTP 服务器收到一个 HTTP 请求时,它不会开始处理队列中的任何其他 HTTP 请求,直到服务器为它收到的第一个 HTTP 请求发送响应。我们在请求处理器中编写的代码是以同步执行方式工作的,并且没有利用 Tornado 中包含的非阻塞特性与异步执行相结合的优势。
为了设置蓝色和白色 LED 的亮度,我们必须发出两个 HTTP PATCH 请求。我们将发出这些请求来了解我们当前版本的 API 如何处理两个传入的请求。
在 Windows 中打开两个 Cygwin 终端或在 macOS 或 Linux 中打开两个终端,并在第一个终端中输入以下命令。我们将编写并发送一个 HTTP 请求来设置蓝色LED的亮度为255。在第一个窗口中写下这一行,但不要按Enter键,因为我们将在两个窗口中几乎同时尝试启动两个命令:
http PATCH :8888/leds/1 brightness_level=255
以下是对应的curl命令:
curl -iX PATCH -H "Content-Type: application/json" -d
'{"brightness_level":255}' :8888/leds/1
现在,转到第二个窗口,并输入以下命令。我们将编写并发送一个 HTTP 请求来设置白色 LED 的亮度为 255。在第二个窗口中写下这一行,但不要按Enter键,因为我们将在两个窗口中几乎同时尝试启动两个命令:
http PATCH :8888/leds/2 brightness_level=255
以下是对应的curl命令:
curl -iX PATCH -H "Content-Type: application/json" -d
'{"brightness_level":255}' :8888/leds/2
现在,转到第一个窗口,按下Enter。然后,转到第二个窗口并迅速按下Enter。你将在运行 Tornado HTTP 服务器的窗口中看到以下行:
I've started setting the Blue LED's brightness level
然后,你将看到以下行,它们显示了执行描述代码何时完成以及随后开始设置 LED 亮度级别的打印语句的结果:
I've finished setting the Blue LED's brightness level
I've started setting the White LED's brightness level
I've finished setting the White LED's brightness level
在服务器能够处理改变白色 LED 亮度级别的 HTTP 请求之前,必须等待改变蓝色 LED 亮度级别的请求完成。以下截图显示了 Windows 上的三个窗口。左侧的窗口正在运行 Tornado HTTP 服务器,并显示处理 HTTP 请求的方法中打印的消息。右上角的窗口正在运行 http 命令以生成改变蓝色 LED 亮度级别的 HTTP 请求。右下角的窗口正在运行 http 命令以生成改变白色 LED 亮度级别的 HTTP 请求。在编写和发送 HTTP 请求以及检查当前 API 版本的同步执行情况时,使用类似的配置是一个好主意:

小贴士
记住,我们在请求处理类中编写的不同方法最终都会调用 time.sleep 来模拟操作执行所需的时间。
由于每个操作都需要一些时间并且会阻塞处理其他传入的 HTTP 请求,我们将创建这个 API 的新版本,它将使用异步执行,我们将了解 Tornado 非阻塞特性的优势。这样,在处理其他请求改变蓝色 LED 亮度级别的同时,就可以改变白色 LED 的亮度级别。Tornado 将能够在 I/O 操作与无人机完成所需时间的同时开始处理请求。
将代码重构以利用异步装饰器
将代码拆分到不同的方法中,如需要处理异步执行完成后执行的回调的异步代码,是非常难以阅读和理解的。幸运的是,Tornado 提供了一个基于生成器的接口,使我们能够在请求处理程序中编写异步代码,并且使用单个生成器。我们可以通过使用 Tornado 提供的 tornado.gen 基于生成器的接口来避免将方法拆分成多个带有回调的方法,从而使在异步环境中工作变得更加容易。
在 Tornado 中编写异步代码的推荐方法是使用协程。因此,我们将重构现有代码,在处理 tornado.web.RequestHandler 子类中不同 HTTP 请求的必要方法中使用 @tornado.gen.coroutine 装饰器来处理异步生成器。
小贴士
与使用回调链工作不同,协程使用 Python 的 yield 关键字来暂停和恢复执行。通过使用协程,我们的代码将像编写同步代码一样简单易懂和维护。
我们将使用 concurrent.futures.ThreadPoolExecutor 类的实例,它为我们提供了一个异步执行可调用对象的高级接口。异步执行将通过线程执行。我们还将使用 @tornado.concurrent.run_on_executor 装饰器在执行器上异步运行同步方法。在这种情况下,我们无人机不同组件提供用于获取和设置数据的方法具有同步执行。我们希望它们以异步执行运行。
创建一个新的 async_api.py 文件。以下行显示了我们将创建的类所需的全部必要导入以及创建名为 thread_pool 的 concurrent.futures.ThreadPoolExecutor 类实例的代码。我们将使用此实例在将重构以进行异步调用的不同方法中。示例的代码文件包含在 restful_python_chapter_10_01 文件夹中:
import status
from datetime import date
from tornado import web, escape, ioloop, httpclient, gen
from concurrent.futures import ThreadPoolExecutor
from tornado.concurrent import run_on_executor
from drone import Altimeter, Drone, Hexacopter, LightEmittingDiode
thread_pool = ThreadPoolExecutor()
drone = Drone()
现在,我们将创建一个 AsyncHexacopterHandler 类,我们将使用它以异步执行来处理对六旋翼机资源的请求。与名为 HexacopterHandler 的同步版本相比,以下行是新的或已更改的。打开先前创建的 async_pi.py 文件,并添加以下行。示例的代码文件包含在 restful_python_chapter_10_01 文件夹中:
class AsyncHexacopterHandler(web.RequestHandler):
SUPPORTED_METHODS = ("GET", "PATCH")
HEXACOPTER_ID = 1
_thread_pool = thread_pool
@gen.coroutine
def get(self, id):
if int(id) is not self.__class__.HEXACOPTER_ID:
self.set_status(status.HTTP_404_NOT_FOUND)
self.finish()
return
print("I've started retrieving hexacopter's status")
hexacopter_status = yield self.retrieve_hexacopter_status()
print("I've finished retrieving hexacopter's status")
response = {
'speed': hexacopter_status.motor_speed,
'turned_on': hexacopter_status.turned_on,
}
self.set_status(status.HTTP_200_OK)
self.write(response)
self.finish()
@run_on_executor(executor="_thread_pool")
def retrieve_hexacopter_status(self):
return drone.hexacopter.get_hexacopter_status()
@gen.coroutine
def patch(self, id):
if int(id) is not self.__class__.HEXACOPTER_ID:
self.set_status(status.HTTP_404_NOT_FOUND)
self.finish()
return
request_data = escape.json_decode(self.request.body)
if ('motor_speed' not in request_data.keys()) or \
(request_data['motor_speed'] is None):
self.set_status(status.HTTP_400_BAD_REQUEST)
self.finish()
return
try:
motor_speed = int(request_data['motor_speed'])
print("I've started setting the hexacopter's motor speed")
hexacopter_status = yield
self.set_hexacopter_motor_speed(motor_speed)
print("I've finished setting the hexacopter's motor speed")
response = {
'speed': hexacopter_status.motor_speed,
'turned_on': hexacopter_status.turned_on,
}
self.set_status(status.HTTP_200_OK)
self.write(response)
self.finish()
except ValueError as e:
print("I've failed setting the hexacopter's motor speed")
self.set_status(status.HTTP_400_BAD_REQUEST)
response = {
'error': e.args[0]
}
self.write(response)
self.finish()
@run_on_executor(executor="_thread_pool")
def set_hexacopter_motor_speed(self, motor_speed):
return drone.hexacopter.set_motor_speed(motor_speed)
AsyncHexacopterHandler 类声明了一个 _thread_pool 类属性,该属性保存了对先前创建的 concurrent.futures.ThreadPoolExecutor 实例的引用。该类声明了两个带有 @run_on_executor(executor="_thread_pool") 装饰器的方法,这使得同步方法可以异步地使用保存在 _thread_pool 类属性中的 concurrent.futures.ThreadPoolExecutor 实例运行。以下是有两个方法:
-
retrieve_hexacopter_status:此方法返回调用drone.hexacopter.get_hexacopter_status方法的结果。 -
set_hexacopter_motor_speed:此方法接收motor_speed参数,并返回调用drone.hexacopter.set_motor_speed方法的结果,其中接收到的motor_speed作为参数。
我们在 get 和 patch 方法上都添加了 @gen.coroutine 装饰器。每当我们需要完成 HTTP 请求时,我们都添加了对 self.finish 的调用。当我们使用 @gen.coroutine 装饰器时,我们有责任调用此方法来完成响应并结束 HTTP 请求。
get 方法使用以下行以非阻塞和异步执行来检索六旋翼机的状态:
hexacopter_status = yield self.retrieve_hexacopter_status()
代码使用 yield 关键字从 self.retrieve_hexacopter_status 返回的 Future 中检索 HexacopterStatus,该 Future 是以异步方式运行的。Future 封装了可调用的异步执行。在这种情况下,Future 封装了 self.retrieve_hexacopter_status 方法的异步执行。接下来的几行不需要更改,我们只需在写入响应后作为最后一行添加对 self.finish 的调用。
get 方法使用以下行以非阻塞和异步方式检索六旋翼机的状态:
hexacopter_status = yield self.retrieve_hexacopter_status()
代码使用 yield 关键字从 self.retrieve_hexacopter_status 返回的 Future 中检索 HexacopterStatus,该 Future 是以异步方式运行的。
patch 方法使用以下行以非阻塞和异步方式设置六旋翼机的电机速度:
hexacopter_status = yield self.set_hexacopter_motor_speed(motor_speed)
代码使用 yield 关键字从 self.set_hexacopter_motor_speed 返回的 Future 中检索 HexacopterStatus,该 Future 是以异步方式运行的。接下来的几行不需要更改,我们只需在写入响应后作为最后一行添加对 self.finish 的调用。
现在,我们将创建一个 AsyncLedHandler 类,我们将使用它来表示 LED 资源并使用异步执行处理请求。与名为 LedHandler 的此处理器的同步版本相比,以下行是新的或已更改的。打开之前创建的 async_pi.py 文件,并添加以下行。示例的代码文件包含在 restful_python_chapter_10_01 文件夹中:
class AsyncLedHandler(web.RequestHandler):
SUPPORTED_METHODS = ("GET", "PATCH")
_thread_pool = thread_pool
@gen.coroutine
def get(self, id):
int_id = int(id)
if int_id not in drone.leds.keys():
self.set_status(status.HTTP_404_NOT_FOUND)
self.finish()
return
led = drone.leds[int_id]
print("I've started retrieving {0}'s status".format(led.description))
brightness_level = yield
self.retrieve_led_brightness_level(led)
print("I've finished retrieving {0}'s status".format(led.description))
response = {
'id': led.identifier,
'description': led.description,
'brightness_level': brightness_level
}
self.set_status(status.HTTP_200_OK)
self.write(response)
self.finish()
@run_on_executor(executor="_thread_pool")
def retrieve_led_brightness_level(self, led):
return led.get_brightness_level()
@gen.coroutine
def patch(self, id):
int_id = int(id)
if int_id not in drone.leds.keys():
self.set_status(status.HTTP_404_NOT_FOUND)
self.finish()
return
led = drone.leds[int_id]
request_data = escape.json_decode(self.request.body)
if ('brightness_level' not in request_data.keys()) or \
(request_data['brightness_level'] is None):
self.set_status(status.HTTP_400_BAD_REQUEST)
self.finish()
return
try:
brightness_level = int(request_data['brightness_level'])
print("I've started setting the {0}'s brightness
level".format(led.description))
yield self.set_led_brightness_level(led, brightness_level)
print("I've finished setting the {0}'s brightness
level".format(led.description))
response = {
'id': led.identifier,
'description': led.description,
'brightness_level': brightness_level
}
self.set_status(status.HTTP_200_OK)
self.write(response)
self.finish()
except ValueError as e:
print("I've failed setting the {0}'s brightness level".format(led.description))
self.set_status(status.HTTP_400_BAD_REQUEST)
response = {
'error': e.args[0]
}
self.write(response)
self.finish()
@run_on_executor(executor="_thread_pool")
def set_led_brightness_level(self, led, brightness_level):
return led.set_brightness_level(brightness_level)
AsyncLedHandler 类声明了一个 _thread_pool 类属性,该属性保存了对之前创建的 concurrent.futures.ThreadPoolExecutor 实例的引用。该类声明了两个带有 @run_on_executor(executor="_thread_pool") 装饰器的方法,这使得同步方法可以与保存在 _thread_pool 类属性中的 concurrent.futures.ThreadPoolExecutor 实例异步运行。以下是有两个方法的示例:
-
retrieve_led_brightness_level: 此方法接收一个在led参数中的LightEmittingDiode实例,并返回调用led.get_brightness_level方法的结果。 -
set_led_brightness_level: 此方法接收一个在led参数中的LightEmittingDiode实例和brightness_level参数。代码使用接收到的brightness_level作为参数调用led.set_brightness_level方法并返回结果。
我们在 get 和 patch 方法上都添加了 @gen.coroutine 装饰器。此外,每当我们想要完成 HTTP 请求时,我们都添加了对 self.finish 的调用。
get 方法使用以下行以非阻塞和异步方式检索 LED 的亮度级别:
brightness_level = yield self.retrieve_led_brightness_level(led)
代码使用 yield 关键字从 self.retrieve_led_brightness_level 返回的 Future 中检索 int,该 Future 以异步执行方式运行。接下来的行不需要更改,我们只需在写入响应后作为最后一行添加对 self.finish 的调用。
patch 方法使用以下行以非阻塞和异步执行方式检索六旋翼机的状态:
hexacopter_status = yield self.retrieve_hexacopter_status()
代码使用 yield 关键字从 self.retrieve_hexacopter_status 返回的 Future 中检索 HexacopterStatus,该 Future 以异步执行方式运行。
patch 方法使用以下行以非阻塞和异步执行来设置 LED 的亮度级别:
yield self.set_led_brightness_level(led, brightness_level)
代码使用 yield 关键字以异步执行方式调用 self.set_led_brightness_level。接下来的行不需要更改,我们只需在写入响应后作为最后一行添加对 self.finish 的调用。
现在,我们将创建一个 AsyncAltimeterHandler 类,我们将使用该类来表示高度计资源,并以异步执行方式处理 get 请求。与名为 AltimeterHandler 的同步版本相比,新或更改的行被突出显示。打开先前创建的 async_pi.py 文件,并添加以下行。示例的代码文件包含在 restful_python_chapter_10_01 文件夹中。
class AsyncAltimeterHandler(web.RequestHandler):
SUPPORTED_METHODS = ("GET")
ALTIMETER_ID = 1
_thread_pool = thread_pool
@gen.coroutine
def get(self, id):
if int(id) is not self.__class__.ALTIMETER_ID:
self.set_status(status.HTTP_404_NOT_FOUND)
self.finish()
return
print("I've started retrieving the altitude")
altitude = yield self.retrieve_altitude()
print("I've finished retrieving the altitude")
response = {
'altitude': altitude
}
self.set_status(status.HTTP_200_OK)
self.write(response)
self.finish()
@run_on_executor(executor="_thread_pool")
def retrieve_altitude(self):
return drone.altimeter.get_altitude()
AsyncAltimeterHandler 类声明了一个 _thread_pool 类属性,该属性保存了对先前创建的 concurrent.futures.ThreadPoolExecutor 实例的引用。该类使用 @run_on_executor(executor="_thread_pool") 装饰器声明了 retrieve_altitude 方法,该装饰器使得同步方法可以异步运行,其引用保存在 _thread_pool 类属性中。retrieve_altitude 方法返回调用 drone.altimeter.get_altitude 方法的结果。
我们在 get 方法中添加了 @gen.coroutine 装饰器。此外,每当我们要完成 HTTP 请求时,我们都添加了对 self.finish 的调用。
get 方法使用以下行以非阻塞和异步执行方式检索高度计的 altitude 值:
altitude = yield self.retrieve_altitude()
代码使用 yield 关键字从 self.retrieve_altitude 返回的 Future 中检索 int,该 Future 以异步执行方式运行。接下来的行不需要更改,我们只需在写入响应后作为最后一行添加对 self.finish 的调用。
将 URL 模式映射到异步请求处理器
我们必须将 URL 模式映射到我们之前编写的 tornado.web.RequestHandler 的子类,这些子类为我们提供了请求处理器的异步方法。以下行创建了应用程序的主入口点,用 API 的 URL 模式初始化它,并开始监听请求。打开之前创建的 async_api.py 文件,并添加以下行。示例代码文件位于 restful_python_chapter_10_01 文件夹中:
application = web.Application([
(r"/hexacopters/([0-9]+)", AsyncHexacopterHandler),
(r"/leds/([0-9]+)", AsyncLedHandler),
(r"/altimeters/([0-9]+)", AsyncAltimeterHandler),
],debug=True)
if __name__ == "__main__":
port = 8888
print("Listening at port {0}".format(port))
application.listen(port)
ioloop.IOLoop.instance().start()
代码创建了一个名为 application 的 tornado.web.Application 实例,其中包含构成 Web 应用的请求处理器集合。我们只是将处理器的名称更改为带有 Async 前缀的新名称。
小贴士
与任何其他 Web 框架一样,你永远不应该在生产环境中启用调试。
向 Tornado 非阻塞 API 发送 HTTP 请求
现在,我们可以运行 async_api.py 脚本,启动 Tornado 的开发服务器,以编写和发送 HTTP 请求到我们新版本的 Web API,该 API 结合了 Tornado 的非阻塞特性和异步执行。执行以下命令:
python async_api.py
以下行显示了执行上一条命令后的输出。Tornado HTTP 开发服务器正在 8888 端口上监听:
Listening at port 8888
使用之前的命令,我们将启动 Tornado HTTP 服务器,它将在 8888 端口上的每个接口上监听。因此,如果我们想从连接到我们局域网的其它计算机或设备向我们的 API 发送 HTTP 请求,我们不需要任何额外的配置。
在我们 API 的新版本中,每个 HTTP 请求都是非阻塞的。因此,每当 Tornado HTTP 服务器收到一个 HTTP 请求并执行异步调用时,它能够在服务器发送第一个接收到的 HTTP 请求的响应之前,开始处理队列中的任何其他 HTTP 请求。我们在请求处理器中编写的这些方法正在使用异步执行,并利用了 Tornado 中包含的非阻塞特性,结合了异步执行。
为了设置蓝色和白色 LED 的亮度级别,我们必须发送两个 HTTP PATCH 请求。我们将通过发送这两个请求来了解我们 API 的新版本如何处理两个传入的请求。
在 Windows 中打开两个 Cygwin 终端,或在 macOS 或 Linux 中打开两个终端,并在第一个终端中写下以下命令。我们将编写并发送一个 HTTP 请求来设置蓝色 LED 的亮度级别为 255。在第一个窗口中写下这一行,但不要按 Enter,因为我们将在两个窗口中几乎同时尝试启动两个命令:
http PATCH :8888/leds/1 brightness_level=255
以下是对应的 curl 命令:
curl -iX PATCH -H "Content-Type: application/json" -d
'{"brightness_level":255}' :8888/leds/1
现在,转到第二个窗口,并写下以下命令。我们将编写并发送一个 HTTP 请求来设置白色 LED 的亮度级别为 255。在第二个窗口中写下这一行,但不要按 Enter,因为我们将在两个窗口中几乎同时尝试启动两个命令:
http PATCH :8888/leds/2 brightness_level=255
以下是对应的curl命令:
curl -iX PATCH -H "Content-Type: application/json" -d '{"brightness_level":255}' :8888/leds/2
现在,转到第一个窗口,按Enter。然后,转到第二个窗口并快速按Enter。您将在运行 Tornado HTTP 服务器的窗口中看到以下几行:
I've started setting the Blue LED's brightness level
I've started setting the White LED's brightness level
然后,您将看到以下几行,它们显示了执行描述代码完成设置 LED 亮度级别的 print 语句的结果:
I've finished setting the Blue LED's brightness level
I've finished setting the White LED's brightness level
服务器可以在更改白色 LED 亮度级别的请求完成其执行之前开始处理更改蓝色 LED 亮度级别的请求。以下截图显示了 Windows 上的三个窗口。左侧的窗口正在运行 Tornado HTTP 服务器并显示处理 HTTP 请求的方法中打印的消息。右上角的窗口正在运行http命令以生成更改蓝色 LED 亮度级别的 HTTP 请求。右下角的窗口正在运行http命令以生成更改白色 LED 亮度级别的 HTTP 请求。在我们编写和发送 HTTP 请求以及检查新版本的 API 上的异步执行如何工作时,使用类似的配置来检查输出是一个好主意:

每个操作都需要一些时间,但由于我们对 API 所做的更改以利用异步执行,因此不会阻塞处理其他传入的 HTTP 请求的可能性。这样,在处理其他请求更改蓝色 LED 亮度级别的同时,可以更改白色 LED 的亮度级别。Tornado 能够在与无人机进行 I/O 操作需要一些时间来完成时开始处理请求。
设置单元测试
我们将使用nose2来简化单元测试的发现和运行。我们将测量测试覆盖率,因此我们将安装必要的包,以便我们可以使用nose2运行覆盖率。首先,我们将在我们的虚拟环境中安装nose2和cov-core包。cov-core包将允许我们使用nose2测量测试覆盖率。
确保您退出 Tornado 的 HTTP 服务器。请记住,您只需在运行它的终端或命令提示符窗口中按Ctrl + C即可。我们只需运行以下命令来安装nose2包,该包还将安装six依赖项:
pip install nose2
输出的最后几行将指示nose2包已成功安装:
Collecting nose2
Collecting six>=1.1 (from nose2)
Downloading six-1.10.0-py2.py3-none-any.whl
Installing collected packages: six, nose2
Successfully installed nose2-0.6.5 six-1.10.0
我们只需运行以下命令来安装cov-core包,该包还将安装coverage依赖项:
pip install cov-core
输出的最后几行将指示django-nose包已成功安装:
Collecting cov-core
Collecting coverage>=3.6 (from cov-core)
Installing collected packages: coverage, cov-core
Successfully installed cov-core-1.15.0 coverage-4.2
打开之前创建的 async_api.py 文件,删除创建名为 application 的 web.Application 实例和 __main__ 方法的行。删除这些行后,添加以下行。示例代码文件包含在 restful_python_chapter_10_02 文件夹中:
class Application(web.Application):
def __init__(self, **kwargs):
handlers = [
(r"/hexacopters/([0-9]+)", AsyncHexacopterHandler),
(r"/leds/([0-9]+)", AsyncLedHandler),
(r"/altimeters/([0-9]+)", AsyncAltimeterHandler),
]
super(Application, self).__init__(handlers, **kwargs)
if __name__ == "__main__":
application = Application()
application.listen(8888)
tornado_ioloop = ioloop.IOLoop.instance()
ioloop.PeriodicCallback(lambda: None, 500, tornado_ioloop).start()
tornado_ioloop.start()
代码声明了一个 Application 类,具体来说,是 tornado.web.Application 的子类,它重写了继承的构造函数,即 __init__ 方法。构造函数声明了 handlers 列表,该列表将 URL 模式映射到异步请求处理器,然后使用列表作为其参数之一调用继承的构造函数。我们创建这个类是为了使测试能够使用这个类。
然后,主方法创建 Application 类的一个实例,注册一个周期性回调,该回调将由 IOLoop 每 500 毫秒执行,以便可以使用 Ctrl + C 停止 HTTP 服务器,并最终调用 start 方法。async_api.py 脚本将继续以相同的方式工作。主要区别在于我们可以在测试中重用 Application 类。
最后,在虚拟环境根文件夹中创建一个名为 .coveragerc 的新文本文件,内容如下。示例代码文件包含在 restful_python_chapter_10_02 文件夹中:
[run]
include = async_api.py, drone.py
这样,coverage 工具在提供测试覆盖率报告时,只会考虑 async_api.py 和 drone.py 文件中的代码。使用这个设置文件,我们将得到一个更准确的测试覆盖率报告。
小贴士
在这种情况下,我们不会为每个环境使用配置文件。然而,在更复杂的应用程序中,你肯定会想使用配置文件。
编写第一轮单元测试
现在,我们将编写第一轮单元测试。具体来说,我们将编写与 LED 资源相关的单元测试。在虚拟环境根文件夹中创建一个新的 tests 子文件夹。然后,在新的 tests 子文件夹中创建一个新的 test_hexacopter.py 文件。添加以下行,声明许多 import 语句和 TextHexacopter 类。示例代码文件包含在 restful_python_chapter_10_02 文件夹中:
import unittest
import status
import json
from tornado import ioloop, escape
from tornado.testing import AsyncHTTPTestCase, gen_test, gen
from async_api import Application
class TestHexacopter(AsyncHTTPTestCase):
def get_app(self):
self.app = Application(debug=False)
return self.app
def test_set_and_get_led_brightness_level(self):
"""
Ensure we can set and get the brightness levels for both LEDs
"""
patch_args_led_1 = {'brightness_level': 128}
patch_args_led_2 = {'brightness_level': 250}
patch_response_led_1 = self.fetch(
'/leds/1',
method='PATCH',
body=json.dumps(patch_args_led_1))
patch_response_led_2 = self.fetch(
'/leds/2',
method='PATCH',
body=json.dumps(patch_args_led_2))
self.assertEqual(patch_response_led_1.code, status.HTTP_200_OK)
self.assertEqual(patch_response_led_2.code, status.HTTP_200_OK)
get_response_led_1 = self.fetch(
'/leds/1',
method='GET')
get_response_led_2 = self.fetch(
'/leds/2',
method='GET')
self.assertEqual(get_response_led_1.code, status.HTTP_200_OK)
self.assertEqual(get_response_led_2.code, status.HTTP_200_OK)
get_response_led_1_data = escape.json_decode(get_response_led_1.body)
get_response_led_2_data = escape.json_decode(get_response_led_2.body)
self.assertTrue('brightness_level' in get_response_led_1_data.keys())
self.assertTrue('brightness_level' in get_response_led_2_data.keys())
self.assertEqual(get_response_led_1_data['brightness_level'],
patch_args_led_1['brightness_level'])
self.assertEqual(get_response_led_2_data['brightness_level'],
patch_args_led_2['brightness_level'])
TestHexacopter 类是 tornado.testing.AsyncHTTPTestCase 的子类,即一个启动 Tornado HTTP 服务器的测试用例。该类重写了 get_app 方法,该方法返回我们想要测试的 tornado.web.Application 实例。在这种情况下,我们返回 async_api 模块中声明的 Application 类的一个实例,将 debug 参数设置为 False。
test_set_and_get_led_brightness_level 方法测试我们是否可以设置和获取白色和蓝色 LED 的亮度级别。代码组合并发送了两个 HTTP PATCH 方法,为 ID 等于 1 和 2 的 LED 设置新的亮度级别值。代码为每个 LED 设置不同的亮度级别。
代码调用 self.fetch 方法来组合并发送 HTTP PATCH 请求,并将要发送到主体的字典作为参数调用 json.dumps。然后,代码再次使用 self.fetch 来组合并发送两个 HTTP GET 方法,以检索亮度值已更改的 LED 的亮度级别值。代码使用 tornado.escape.json_decode 将响应主体的字节转换为 Python 字典。该方法使用 assertEqual 和 assertTrue 来检查以下预期结果:
-
两个 HTTP
PATCH响应的status_code是 HTTP 200 OK (status.HTTP_200_OK) -
两个 HTTP
GET响应的status_code是 HTTP 200 OK (status.HTTP_200_OK) -
两个 HTTP
GET响应的响应体包含一个名为brigthness_level的键 -
HTTP
GET响应中brightness_level键的值等于每个 LED 设置的亮度级别
使用 nose2 运行单元测试并检查测试覆盖率
现在,运行以下命令以在我们的测试数据库中创建所有必要的表,并使用 nose2 测试运行器执行我们创建的所有测试。测试运行器将执行我们 TestHexacopter 类中以 test_ 前缀开头的方法,并将显示结果。在这种情况下,我们只有一个符合标准的方法,但稍后我们会添加更多。
在我们一直在使用的同一虚拟环境中运行以下命令。我们将使用 -v 选项指示 nose2 打印测试用例名称和状态。--with-coverage 选项打开测试覆盖率报告生成:
nose2 -v --with-coverage
以下行显示了示例输出。请注意,如果我们的代码包含额外的行或注释,报告中显示的数字可能会有所不同:
test_set_and_get_led_brightness_level (test_hexacopter.TestHexacopter) ...
I've started setting the Blue LED's brightness level
I've finished setting the Blue LED's brightness level
I've started setting the White LED's brightness level
I've finished setting the White LED's brightness level
I've started retrieving Blue LED's status
I've finished retrieving Blue LED's status
I've started retrieving White LED's status
I've finished retrieving White LED's status
ok
----------------------------------------------------------------
Ran 1 test in 1.311s
OK
----------- coverage: platform win32, python 3.5.2-final-0 -----
Name Stmts Miss Cover
----------------------------------
async_api.py 129 69 47%
drone.py 57 18 68%
----------------------------------
TOTAL 186 87 53%
默认情况下,nose2 会查找以 test 前缀开头的模块。在这种情况下,唯一符合标准的模块是 test_hexacopter 模块。在符合标准的模块中,nose2 会从 unittest.TestCase 的所有子类以及以 test 前缀开头的方法中加载测试。tornado.testing.AsyncHTTPTestCase 在其类层次结构中将 unittest.TestCase 作为其超类之一。
提供的输出详细说明了测试运行器发现并执行了一个测试,并且它通过了。输出显示了 TestHexacopter 类中以 test_ 前缀开头的方法及其类名,这些方法代表要执行的测试。
根据报告中显示的测量结果,async_api.py 和 drone.py 的覆盖率确实非常低。实际上,我们只编写了一个与 LED 相关的测试,因此提高覆盖率是有意义的。我们没有创建与其他六旋翼机资源相关的测试。
我们可以使用带有 -m 命令行选项的 coverage 命令来显示新 Missing 列中缺失语句的行号:
coverage report -m
命令将使用上次执行的信息,并显示遗漏的语句。以下几行显示了与之前单元测试执行相对应的示例输出。请注意,如果我们的代码包含额外的行或注释,报告中显示的数字可能会有所不同:
Name Stmts Miss Cover Missing
--------------------------------------------
async_api.py 129 69 47% 137-150, 154, 158-187, 191, 202-204, 226-228, 233-235, 249-256, 270-282, 286, 311-315
drone.py 57 18 68% 11-12, 24, 27-34, 37, 40-41, 59, 61, 68-69
--------------------------------------------
TOTAL 186 87 53%
现在,运行以下命令以获取详细说明遗漏行的注释 HTML 列表:
coverage html
使用您的 Web 浏览器打开在htmlcov文件夹中生成的index.html HTML 文件。以下截图显示了一个示例报告,该报告以 HTML 格式生成覆盖率:

点击或轻触drony.py,Web 浏览器将渲染一个显示已运行语句、遗漏语句和排除语句的 Web 页面,这些语句用不同的颜色表示。我们可以点击或轻触运行、遗漏和排除按钮来显示或隐藏代表每行代码状态的背景颜色。默认情况下,遗漏的代码行将以粉红色背景显示。因此,我们必须编写针对这些代码行的单元测试来提高我们的测试覆盖率。

提高测试覆盖率
现在,我们将编写额外的单元测试来提高测试覆盖率。具体来说,我们将编写与六旋翼电机和高度计相关的单元测试。打开现有的test_hexacopter.py文件,在最后一行之后插入以下行。示例的代码文件包含在restful_python_chapter_10_03文件夹中:
def test_set_and_get_hexacopter_motor_speed(self):
"""
Ensure we can set and get the hexacopter's motor speed
"""
patch_args = {'motor_speed': 700}
patch_response = self.fetch(
'/hexacopters/1',
method='PATCH',
body=json.dumps(patch_args))
self.assertEqual(patch_response.code, status.HTTP_200_OK)
get_response = self.fetch(
'/hexacopters/1',
method='GET')
self.assertEqual(get_response.code, status.HTTP_200_OK)
get_response_data = escape.json_decode(get_response.body)
self.assertTrue('speed' in get_response_data.keys())
self.assertTrue('turned_on' in get_response_data.keys())
self.assertEqual(get_response_data['speed'],
patch_args['motor_speed'])
self.assertEqual(get_response_data['turned_on'],
True)
def test_get_altimeter_altitude(self):
"""
Ensure we can get the altimeter's altitude
"""
get_response = self.fetch(
'/altimeters/1',
method='GET')
self.assertEqual(get_response.code, status.HTTP_200_OK)
get_response_data = escape.json_decode(get_response.body)
self.assertTrue('altitude' in get_response_data.keys())
self.assertGreaterEqual(get_response_data['altitude'],
0)
self.assertLessEqual(get_response_data['altitude'],
3000)
之前的代码向TestHexacopter类添加了以下两个以test_前缀开头的方法:
-
test_set_and_get_hexacopter_motor_speed:这个测试检查我们是否可以设置和获取六旋翼的电机速度。 -
test_get_altimeter_altitude:这个测试检查我们是否可以从高度计检索高度值。
我们只是编写了一些与六旋翼和高度计相关的测试,以提高测试覆盖率并注意对测试覆盖率报告的影响。
现在,在同一个虚拟环境中运行以下命令:
nose2 -v --with-coverage
以下几行显示了示例输出。请注意,如果我们的代码包含额外的行或注释,报告中显示的数字可能会有所不同:
test_get_altimeter_altitude (test_hexacopter.TestHexacopter) ...
I've started retrieving the altitude
I've finished retrieving the altitude
ok
test_set_and_get_hexacopter_motor_speed (test_hexacopter.TestHexacopter) ... I've started setting the hexacopter's motor speed
I've finished setting the hexacopter's motor speed
I've started retrieving hexacopter's status
I've finished retrieving hexacopter's status
ok
test_set_and_get_led_brightness_level (test_hexacopter.TestHexacopter) ... I've started setting the Blue LED's brightness level
I've finished setting the Blue LED's brightness level
I've started setting the White LED's brightness level
I've finished setting the White LED's brightness level
I've started retrieving Blue LED's status
I've finished retrieving Blue LED's status
I've started retrieving White LED's status
I've finished retrieving White LED's status
ok
--------------------------------------------------------------
Ran 3 tests in 2.282s
OK
----------- coverage: platform win32, python 3.5.2-final-0 ---
Name Stmts Miss Cover
----------------------------------
async_api.py 129 38 71%
drone.py 57 4 93%
----------------------------------
TOTAL 186 42 77%
输出提供了详细信息,表明测试运行器执行了3个测试,并且所有测试都通过了。由coverage包提供的测试代码覆盖率测量报告将async_api.py模块的Cover百分比从上次运行的47%提高到71%。此外,drone.py模块的百分比从68%提高到93%,因为我们编写了与无人机所有组件一起工作的测试。我们编写的新附加测试在这两个模块中执行了额外的代码,因此覆盖率报告中有影响。
如果我们查看未执行的语句,我们会注意到我们没有测试验证失败的场景。现在,我们将编写额外的单元测试来进一步提高测试覆盖率。具体来说,我们将编写单元测试以确保我们无法为 LED 设置无效的亮度级别,我们无法为六旋翼机设置无效的电机速度,并且当我们尝试访问不存在的资源时,我们会收到 HTTP 404 Not Found 状态码。打开现有的 test_hexacopter.py 文件,在最后一行之后插入以下行。示例代码文件包含在 restful_python_chapter_10_04 文件夹中:
def test_set_invalid_brightness_level(self):
"""
Ensure we cannot set an invalid brightness level for a LED
"""
patch_args_led_1 = {'brightness_level': 256}
patch_response_led_1 = self.fetch(
'/leds/1',
method='PATCH',
body=json.dumps(patch_args_led_1))
self.assertEqual(patch_response_led_1.code, status.HTTP_400_BAD_REQUEST)
patch_args_led_2 = {'brightness_level': -256}
patch_response_led_2 = self.fetch(
'/leds/2',
method='PATCH',
body=json.dumps(patch_args_led_2))
self.assertEqual(patch_response_led_2.code, status.HTTP_400_BAD_REQUEST)
patch_response_led_3 = self.fetch(
'/leds/2',
method='PATCH',
body=json.dumps({}))
self.assertEqual(patch_response_led_3.code, status.HTTP_400_BAD_REQUEST)
def test_set_brightness_level_invalid_led_id(self):
"""
Ensure we cannot set the brightness level for an invalid LED id
"""
patch_args_led_1 = {'brightness_level': 128}
patch_response_led_1 = self.fetch(
'/leds/100',
method='PATCH',
body=json.dumps(patch_args_led_1))
self.assertEqual(patch_response_led_1.code, status.HTTP_404_NOT_FOUND)
def test_get_brightness_level_invalid_led_id(self):
"""
Ensure we cannot get the brightness level for an invalid LED id
"""
patch_response_led_1 = self.fetch(
'/leds/100',
method='GET')
self.assertEqual(patch_response_led_1.code, status.HTTP_404_NOT_FOUND)
def test_set_invalid_motor_speed(self):
"""
Ensure we cannot set an invalid motor speed for the hexacopter
"""
patch_args_hexacopter_1 = {'motor_speed': 89000}
patch_response_hexacopter_1 = self.fetch(
'/hexacopters/1',
method='PATCH',
body=json.dumps(patch_args_hexacopter_1))
self.assertEqual(patch_response_hexacopter_1.code,
status.HTTP_400_BAD_REQUEST)
patch_args_hexacopter_2 = {'motor_speed': -78600}
patch_response_hexacopter_2 = self.fetch(
'/hexacopters/1',
method='PATCH',
body=json.dumps(patch_args_hexacopter_2))
self.assertEqual(patch_response_hexacopter_2.code,
status.HTTP_400_BAD_REQUEST)
patch_response_hexacopter_3 = self.fetch(
'/hexacopters/1',
method='PATCH',
body=json.dumps({}))
self.assertEqual(patch_response_hexacopter_3.code,
status.HTTP_400_BAD_REQUEST)
def test_set_motor_speed_invalid_hexacopter_id(self):
"""
Ensure we cannot set the motor speed for an invalid hexacopter id
"""
patch_args_hexacopter_1 = {'motor_speed': 128}
patch_response_hexacopter_1 = self.fetch(
'/hexacopters/100',
method='PATCH',
body=json.dumps(patch_args_hexacopter_1))
self.assertEqual(patch_response_hexacopter_1.code,
status.HTTP_404_NOT_FOUND)
def test_get_motor_speed_invalid_hexacopter_id(self):
"""
Ensure we cannot get the motor speed for an invalid hexacopter id
"""
patch_response_hexacopter_1 = self.fetch(
'/hexacopters/5',
method='GET')
self.assertEqual(patch_response_hexacopter_1.code,
status.HTTP_404_NOT_FOUND)
def test_get_altimeter_altitude_invalid_altimeter_id(self):
"""
Ensure we cannot get the altimeter's altitude for an invalid altimeter id
"""
get_response = self.fetch(
'/altimeters/5',
method='GET')
self.assertEqual(get_response.code, status.HTTP_404_NOT_FOUND)
之前的代码向 TestHexacopter 类添加了以下七个以 test_ 前缀开头的方法:
-
test_set_invalid_brightness_level: 这确保了我们无法通过 HTTPPATCH请求为 LED 设置无效的亮度级别。 -
test_set_brightness_level_invalid_led_id: 这确保了我们无法通过 HTTPPATCH请求为无效的 LED ID 设置亮度级别。 -
test_get_brightness_level_invalid_led_id: 这确保了我们无法获取无效 LED ID 的亮度级别。 -
test_set_invalid_motor_speed: 这确保了我们无法通过 HTTPPATCH请求为六旋翼机设置无效的电机速度。 -
test_set_motor_speed_invalid_hexacopter_id: 这确保了我们无法通过 HTTPPATCH请求为无效的六旋翼机 ID 设置电机速度。 -
test_get_motor_speed_invalid_hexacopter_id: 这确保了我们无法获取无效六旋翼机 ID 的电机速度。 -
test_get_altimeter_altitude_invalid_altimeter_id: 这确保了我们无法获取无效高度计 ID 的高度值。
我们编写了许多测试,以确保所有验证都能按预期工作。现在,在同一个虚拟环境中运行以下命令:
nose2 -v --with-coverage
以下行显示了示例输出。请注意,如果我们的代码包含额外的行或注释,报告中显示的数字可能会有所不同:
I've finished retrieving the altitude
ok
test_get_altimeter_altitude_invalid_altimeter_id (test_hexacopter.TestHexacopter) ... WARNING:tornado.access:404 GET /altimeters/5 (127.0.0.1) 1.00ms
ok
test_get_brightness_level_invalid_led_id (test_hexacopter.TestHexacopter) ... WARNING:tornado.access:404 GET /leds/100 (127.0.0.1) 2.01ms
ok
test_get_motor_speed_invalid_hexacopter_id (test_hexacopter.TestHexacopter) ... WARNING:tornado.access:404 GET /hexacopters/5 (127.0.0.1) 2.01ms
ok
test_set_and_get_hexacopter_motor_speed (test_hexacopter.TestHexacopter) ... I've started setting the hexacopter's motor speed
I've finished setting the hexacopter's motor speed
I've started retrieving hexacopter's status
I've finished retrieving hexacopter's status
ok
test_set_and_get_led_brightness_level (test_hexacopter.TestHexacopter) ... I've started setting the Blue LED's brightness level
I've finished setting the Blue LED's brightness level
I've started setting the White LED's brightness level
I've finished setting the White LED's brightness level
I've started retrieving Blue LED's status
I've finished retrieving Blue LED's status
I've started retrieving White LED's status
I've finished retrieving White LED's status
ok
test_set_brightness_level_invalid_led_id (test_hexacopter.TestHexacopter) ... WARNING:tornado.access:404 PATCH /leds/100 (127.0.0.1) 1.01ms
ok
test_set_invalid_brightness_level (test_hexacopter.TestHexacopter) ... I've started setting the Blue LED's brightness level
I've failed setting the Blue LED's brightness level
WARNING:tornado.access:400 PATCH /leds/1 (127.0.0.1) 13.51ms
I've started setting the White LED's brightness level
I've failed setting the White LED's brightness level
WARNING:tornado.access:400 PATCH /leds/2 (127.0.0.1) 10.03ms
WARNING:tornado.access:400 PATCH /leds/2 (127.0.0.1) 2.01ms
ok
test_set_invalid_motor_speed (test_hexacopter.TestHexacopter) ... I've started setting the hexacopter's motor speed
I've failed setting the hexacopter's motor speed
WARNING:tornado.access:400 PATCH /hexacopters/1 (127.0.0.1) 19.27ms
I've started setting the hexacopter's motor speed
I've failed setting the hexacopter's motor speed
WARNING:tornado.access:400 PATCH /hexacopters/1 (127.0.0.1) 9.04ms
WARNING:tornado.access:400 PATCH /hexacopters/1 (127.0.0.1) 1.00ms
ok
test_set_motor_speed_invalid_hexacopter_id (test_hexacopter.TestHexacopter) ... WARNING:tornado.access:404 PATCH /hexacopters/100 (127.0.0.1) 1.00ms
ok
----------------------------------------------------------------------
Ran 10 tests in 5.905s
OK
----------- coverage: platform win32, python 3.5.2-final-0 -----------
Name Stmts Miss Cover
----------------------------------
async_api.py 129 5 96%
drone.py 57 0 100%
----------------------------------
TOTAL 186 5 97%
输出提供了详细信息,表明测试运行器执行了 10 个测试,并且所有测试都通过了。由 coverage 包提供的测试代码覆盖率测量报告将 async_api.py 模块的 Cover 百分比从上一次运行的 71% 提高到了 97%。此外,drone.py 模块的百分比从 93% 提高到了 100%。如果我们检查覆盖率报告,我们会注意到未执行的唯一语句是 async_api.py 模块主方法中包含的语句,因为它们不是测试的一部分。因此,我们可以说是 100% 的覆盖率。
现在我们有了很好的测试覆盖率,我们可以生成包含应用程序依赖及其版本的 requirements.txt 文件。这样,我们决定部署 RESTful API 的任何平台都将能够轻松安装文件中列出的所有必要依赖项。
执行以下 pip freeze 命令以生成 requirements.txt 文件:
pip freeze > requirements.txt
以下行显示了生成的示例 requirements.txt 文件的内容。然而,请注意,许多包的版本号更新很快,您可能在自己的配置中看到不同的版本:
cov-core==1.15.0
coverage==4.2
nose2==0.6.5
six==1.10.0
tornado==4.4.1
用于构建 RESTful API 的其他 Python Web 框架
我们使用 Django、Flask 和 Tornado 构建了 RESTful Web 服务。然而,Python 有许多其他适合构建 RESTful API 的 Web 框架。本书中关于设计、构建、测试和部署 RESTful API 的所有知识也适用于我们决定使用的任何其他 Python Web 框架。以下列表列举了额外的框架及其主要网页:
-
Pyramid:
www.pylonsproject.org/ -
Bottle:
bottlepy.org/ -
Falcon:
falconframework.org/
就像任何 Python Web 框架一样,可能还有额外的包可以简化我们的常见任务。例如,可以使用 Ramses 与 Pyramid 结合,通过使用 RAML(RESTful API Modeling Language)来创建 RESTful API,其规范可在 github.com/raml-org/raml-spec 找到。您可以在 ramses.readthedocs.io/en/stable/getting_started.html 阅读更多关于 Ramses 的详细信息。
测试您的知识
-
concurrent.futures.ThreadPoolExecutor类为我们提供:-
一个用于同步执行可调用对象的高级接口。
-
一个用于异步执行可调用对象的高级接口。
-
一个用于组合 HTTP 请求的高级接口。
-
-
@tornado.concurrent.run_on_executor装饰器允许我们:-
在执行器上同步运行异步方法。
-
在执行器上运行异步方法而不生成 Future。
-
在执行器上异步运行同步方法。
-
-
在 Tornado 中编写异步代码的推荐方法是使用:
-
协程。
-
链式回调。
-
子程序。
-
-
tornado.Testing.AsyncHTTPTestCase类表示:-
启动 Flask HTTP 服务器的测试用例。
-
启动 Tornado HTTP 服务器的测试用例。
-
不启动任何 HTTP 服务器的测试用例。
-
-
如果我们要将 JSON 响应体中的字节转换为 Python 字典,可以使用以下函数:
-
tornado.escape.json_decode -
tornado.escape.byte_decode -
tornado.escape.response_body_decode
-
摘要
在本章中,我们理解了同步执行和异步执行之间的区别。我们创建了一个新的 RESTful API 版本,它利用了 Tornado 中的非阻塞特性以及异步执行。我们提高了现有 API 的可扩展性,并使其在等待传感器和执行器的慢速 I/O 操作时能够开始执行其他请求。我们通过使用 Tornado 提供的基于 tornado.gen 生成器的接口来避免将方法拆分成多个带有回调的方法,这使得在异步环境中工作变得更加容易。
然后,我们设置了测试环境。我们安装了 nose2 以便于发现和执行单元测试。我们编写了一轮单元测试,测量了测试覆盖率,然后编写了额外的单元测试以提高测试覆盖率。我们创建了所有必要的测试,以确保对所有代码行都有完整的覆盖。
我们使用 Django、Flask 和 Tornado 构建了 RESTful Web 服务。我们为每种情况选择了最合适的框架。我们学会了从头设计 RESTful API,并运行所有必要的测试以确保我们的 API 在发布新版本时没有问题。现在,我们准备好使用本书中一直使用的任何 Web 框架来创建 RESTful API。
第十一章。练习答案
第一章,使用 Django 开发 RESTful API
| Q1 | 2 |
|---|---|
| Q2 | 1 |
| Q3 | 3 |
| Q4 | 1 |
| Q5 | 3 |
第二章,在 Django 中使用基于类的视图和超链接 API
| Q1 | 1 |
|---|---|
| Q2 | 2 |
| Q3 | 3 |
| Q4 | 3 |
| Q5 | 1 |
第三章,使用 Django 改进和添加认证到 API
| Q1 | 3 |
|---|---|
| Q2 | 1 |
| Q3 | 2 |
| Q4 | 1 |
| Q5 | 3 |
第四章,使用 Django 进行 API 的节流、过滤、测试和部署
| Q1 | 2 |
|---|---|
| Q2 | 1 |
| Q3 | 3 |
| Q4 | 1 |
| Q5 | 1 |
第五章,使用 Flask 开发 RESTful API
| Q1 | 1 |
|---|---|
| Q2 | 3 |
| Q3 | 3 |
| Q4 | 2 |
| Q5 | 1 |
第六章,在 Flask 中使用模型、SQLAlchemy 和超链接 API
| Q1 | 1 |
|---|---|
| Q2 | 2 |
| Q3 | 3 |
| Q4 | 3 |
| Q5 | 1 |
第七章,使用 Flask 改进和添加认证到 API
| Q1 | 3 |
|---|---|
| Q2 | 1 |
| Q3 | 3 |
| Q4 | 1 |
| Q5 | 2 |
第八章,使用 Flask 测试和部署 API
| Q1 | 1 |
|---|---|
| Q2 | 2 |
| Q3 | 1 |
| Q4 | 1 |
| Q5 | 3 |
第九章,使用 Tornado 开发 RESTful API
| Q1 | 2 |
|---|---|
| Q2 | 1 |
| Q3 | 3 |
| Q4 | 3 |
| Q5 | 2 |
第十章,使用 Tornado 进行异步代码、测试和部署 API
| Q1 | 2 |
|---|---|
| Q2 | 3 |
| Q3 | 1 |
| Q4 | 2 |
| Q5 | 1 |


浙公网安备 33010602011771号