Skip to content

Latest commit

 

History

History
2896 lines (1979 loc) · 142 KB

File metadata and controls

2896 lines (1979 loc) · 142 KB

十二、网络服务

在本章中,我们将介绍以下配方:

  • 用 WSGI 实现 web 服务
  • 将 Flask 框架用于 RESTful API
  • 解析请求中的查询字符串
  • 使用 urllib 发出 REST 请求
  • 解析 URL 路径
  • 解析 JSON 请求
  • 实现 web 服务的身份验证

导言

提供 web 服务涉及解决几个相互关联的问题。必须遵循许多适用的协议,每个协议都有其独特的设计考虑。web 服务的核心是定义 HTTP 的各种标准。

HTTP 涉及两方,;客户端和服务器:

  • 客户端向服务器发出请求
  • 服务器将响应发送回客户端

这种关系是高度不对称的。我们期望服务器处理来自多个客户端的并发请求。由于客户端请求是异步到达的,服务器无法轻松区分来自单个用户的请求。人类用户会话的概念是通过设计一个服务器来实现的,该服务器提供会话令牌(或 cookie)来跟踪人类对当前状态的感知。

HTTP 协议是灵活和可扩展的。HTTP 的一个流行用例是以网页的形式提供内容。网页通常编码为 HTML 文档,通常带有指向图形、样式表和 JavaScript 代码的链接。我们已经在第 9 章输入/输出、物理格式和逻辑布局阅读 HTML 文档配方中查看了解析 HTML。

服务网页内容进一步分解为两种内容:

  • 静态内容本质上是文件的下载。GUnicorn、KingX 或 ApacheHTTPD 等程序可以可靠地服务于静态文件。每个 URL 定义文件的路径,服务器将文件下载到浏览器。
  • 动态内容由应用程序根据需要构建。在本例中,我们将使用 Python 应用程序来构建唯一的 HTML(或图形),以响应请求。

HTTP 的另一个非常流行的用例是提供 web 服务。在这种情况下,标准 HTTP 请求和响应将以 HTML 以外的格式交换数据。最流行的信息编码格式之一是 JSON。我们已经在阅读第 9 章中的 JSON 文档配方、输入/输出、物理格式和逻辑布局中了解了如何处理 JSON 文档。

Web 服务可以看作是使用 HTTP 服务动态内容的一种变体。客户机可以准备 JSON 格式的文档。服务器包括一个 Python 应用程序,该应用程序创建响应文档,也使用 JSON 表示法。

在某些情况下,服务的重点非常狭窄。可以将服务和数据库持久性捆绑到单个包中。这可能涉及到创建一个服务器,该服务器具有基于 NGINX 的 web 界面以及使用 MongoDB 或 Elastic 的数据库。整个包 web 服务加上持久性可以称为一个微服务

web 服务交换的文档对对象状态的表示进行编码。JavaScript 中的客户端应用程序可能具有发送到服务器的对象状态。Python 中的服务器可以将对象状态的表示形式传输给客户端。这称为表征状态转移休息。使用 REST 处理的服务通常称为 RESTful。

处理 HTTP for HTML 或 JSON 可以设计为许多转换函数。其思路如下:

    response = F(request, persistent state) 

响应由某个函数F(r, s)根据请求生成,该函数依赖于请求加上服务器上数据库中的一些持久状态。

这些函数围绕核心服务形成嵌套的 shell 或包装器。例如,核心处理可以用附加步骤包装,以确保发出请求的用户有权更改数据库状态。我们可以总结如下:

    response = auth(F(request, persistent state)) 

授权处理可以被包装在处理中以认证用户的凭证。所有这些都可以进一步封装在一个 shell 中,以确保客户端应用程序软件期望得到 JSON 表示法的响应。使用这样的多层可以为许多不同的核心服务提供一致的操作。整个过程可能开始如下所示:

    response = JSON( user( auth( F(request, persistent state) ) ) ) 

这种设计与一系列转换功能自然契合。这一思想为我们设计复杂的 web 服务提供了一些指导,这些 web 服务包括许多协议和许多创建有效响应的规则。

一个好的 RESTful 实现还应该提供大量关于服务的信息。提供此信息的一种方法是通过 OpenAPI 规范。有关 OpenAPI(Swagger)规范的信息,请参见http://swagger.io/specification/

OpenAPI 规范的核心是 JSON 模式规范。有关这方面的更多信息,请参见http://json-schema.org

这两个基本思想如下:

  1. 我们用 JSON 为发送到服务的请求和服务提供的响应编写规范。
  2. 我们在固定的 URL 上提供规范,通常为/swagger.json。客户机可以查询此信息,以确定服务工作方式的详细信息。

创建大摇大摆的文档可能是一项挑战。swagger-spec-validator项目可以有所帮助。参见https://pypi.python.org/pypi/swagger-spec-validator 。这是一个 Python 包,我们可以使用它来确认 Swagger 规范满足 OpenAPI 要求。

在本章中,我们将介绍一些创建 RESTful web 服务以及提供静态或动态内容的方法。

用 WSGI 实现 web 服务

许多 web 应用程序将具有多个层。这些层通常可以概括为三种常见模式:

  • 表示层可以在移动设备或网站上运行。这是可见的外部视图。
  • 应用程序层通常实现为 web 服务。该层处理 web 或移动演示文稿。
  • 持久性层处理单个会话上以及单个用户跨多个会话的数据和事务状态保留。这将支持应用层。

基于 Python 的网站或 web 服务应用程序将遵循web 服务网关接口WSGI标准。这为前端 web 服务器(如 apachehttpd、NGINX 或 GUnicorn)提供了使用 Python 提供动态内容的统一方法。

Python 有多种 RESTful API 框架。在为 RESTful API 使用 Flask 框架配方中,我们将介绍 Flask。然而,在某些情况下,我们只需要 WSGI 的核心功能。

我们如何创建支持遵循 WSGI 标准的分层组合的应用程序?

准备好了吗

WSGI 标准为可组合的 web 应用程序定义了一个总体框架。这背后的思想是定义每个应用程序,使其独立并可以轻松地连接到其他应用程序。整个网站是建立在一个贝壳或包装的集合。

这是一种简单的 web 服务器开发方法。WSGI 不是一个复杂的框架;这是最低标准。我们将在中使用用于 RESTful API 的 Flask 框架配方中使用更好的框架来简化设计。

web 服务的本质是 HTTP 请求和响应。服务器接收请求并创建响应。HTTP 请求包括几段数据:

  • The URL for the resource. A URL can be as complex as http://www.example.com:8080/?query#fragment . There are several parts to a URL:

    • 方案http:以:结尾。
    • 主机www.example.com:前缀为//。它可能包括一个可选端口号。在这种情况下,它是8080
    • 资源的路径:本例中的/字符。某种形式的路径是必需的。它通常比简单的/更复杂。
    • ?开头的查询字符串:在本例中,查询字符串只是键query,没有值。
    • #开头的片段标识符:在本例中,片段为fragment。对于 HTML 文档,这可以是特定标记的id值;浏览器将滚动到指定的标记。

    几乎所有这些 URL 元素都是可选的。我们可以利用查询字符串(或片段)提供有关请求的其他格式信息。

WSGI 标准要求解析 URL。放入环境中的各种部件。每个零件将分配一个单独的键:

  • 方法:常见的 HTTP 方法有HEADOPTIONSGETPOSTPUTDELETE等。
  • 请求头:头是支持请求的附加信息。例如,标题用于定义可接受的内容类型。
  • 附加内容:请求可能包括来自 HTML 表单的输入,或者需要上传的文件。

HTTP 响应在许多方面类似于请求。它包含响应头和响应体。标题将包括内容编码等详细信息,以便客户端能够正确呈现内容。如果服务器提供 HTML 内容并维护服务器会话,则 Cookie 将作为每个请求和响应的一部分以头的形式发送。

WSGI 旨在帮助创建可用于构建更大、更复杂应用程序的应用程序组件。WSGI 应用程序通常起包装器的作用,将其他应用程序与坏请求、未经授权的用户或未经身份验证的用户隔离开来。为此,每个 WSGI 应用程序必须遵循一个通用的标准定义。每个应用程序必须是具有以下签名的函数或可调用对象:

    def application(environ, start_response): 
        start_response('200 OK', [('Content-Type', 'text/plain')]) 
        return iterable_strings 

environ参数是一个包含请求信息的字典。这包括所有 HTTP 详细信息、操作系统上下文和 WSGI 服务器上下文。start_response参数是在返回响应主体之前必须调用的函数。这将提供响应的状态和标题。

WSGI 应用程序函数的返回值是 HTTP 响应体。这通常是字符串序列或字符串值上的 iterable。这里的想法是,WSGI 应用程序可能是一个更大容器的一部分,该容器将在构建响应时将响应分块从服务器流到客户机。

由于所有 WSGI 应用程序都是可调用函数,因此它们可以轻松组合。一个复杂的 web 服务器可能有几个 WSGI 组件来处理身份验证、授权、标准头、审计日志、性能监视等细节。这些方面通常独立于基础内容;它们是所有 web 应用程序或 RESTful 服务的通用特性。

我们将看一个相对简单的 web 服务,它从一副牌或一只鞋中发出扑克牌。我们将依赖于第 6 章类和对象基础中的使用【Uuuuuuuuuuuuuuut3】配方优化小对象中的Card类定义。下面是包含等级和西装信息的核心Card类:

    class Card: 
        __slots__ = ('rank', 'suit') 
        def __init__(self, rank, suit): 
            self.rank = int(rank) 
            self.suit = suit 
        def __repr__(self): 
            return ("Card(rank={self.rank!r}, " 
             "suit={self.suit!r})").format(self=self) 
        def to_json(self): 
            return { 
                "__class__": "Card",  
                'rank': self.rank,  
                'suit': self.suit} 

我们已经为扑克牌定义了一个小的基类。类的每个实例都有两个属性,ranksuit。我们省略了散列和比较方法的定义。按照第 7 章更高级的类设计中的创建具有可订购对象的类配方,该类需要一些额外的特殊方法。这个食谱将避免这些复杂情况。

我们已经定义了一个to_json()方法,可以方便地将这个复杂对象序列化为一致的 JSON 格式。该方法发出一个表示Card状态的字典。如果我们想从 JSON 符号反序列化Card对象,我们还需要创建一个object_hook函数。不过,这个配方不需要它,因为我们不接受Card对象作为输入。

我们还需要一个Deck类作为Card实例的容器。这个类的一个实例可以创建Card实例,也可以作为一个有状态对象来处理卡片。下面是类定义:

    import random 
     class Deck: 
        SUITS = ( 
            '\N{black spade suit}', 
            '\N{white heart suit}', 
            '\N{white diamond suit}', 
            '\N{black club suit}', 
        ) 

        def __init__(self, n=1): 
            self.n = n 
            self.create_deck(self.n) 

        def create_deck(self, n=1): 
            self.cards = [ 
                Card(r,s)  
                    for r in range(1,14)  
                        for s in self.SUITS  
                            for _ in range(n) 
            ] 
            random.shuffle(self.cards) 
            self.offset = 0 

        def deal(self, hand_size=5): 
            if self.offset + hand_size > len(self.cards): 
                self.create_deck(self.n) 
            hand = self.cards[self.offset:self.offset+hand_size] 
            self.offset += hand_size 
            return hand 

create_deck()方法使用生成器创建十三个等级和四套西装的所有 52 种组合。每件西装由一个字符定义:♣, ♢, ♡, 或♠. 该示例使用\N{}序列拼写 Unicode 字符名。

如果在创建Deck实例时提供了n值,则容器将创建 52 个卡片组的多个副本。这种多层鞋有时可以通过减少洗牌时间来加速比赛。创建Card实例序列后,使用random模块对其进行洗牌。对于可重复的测试用例,可以提供固定的种子。

deal()方法将使用self.offset的值来确定从何处开始交易。该值从0开始,在每一手牌发完后递增。hand_size参数决定下一手牌的数量。此方法通过增加self.offset的值来更新对象的状态,以便只发一次卡。

下面是使用此类创建Card对象的一种方法:

>>> from ch12_r01 import deck_factory 
>>> import random 
>>> import json 

>>> random.seed(2) 
>>> deck = Deck() 
>>> cards = deck.deal(5) 
>>> cards   
[Card(rank=4, suit='♠'), Card(rank=8, suit='♡'), 
 Card(rank=3, suit='♡'), Card(rank=6, suit='♡'), 
 Card(rank=2, suit='♣')]

为了创建一个合理的测试,我们提供了一个固定的种子值。脚本使用Deck()创建了一个单组。然后我们可以从牌堆中处理五个Card实例。

为了将其用作 web 服务的一部分,我们还需要以 JSON 表示法生成有用的输出。下面是一个这样的例子:

>>> json_cards = list(card.to_json() for card in deck.deal(5)) 
>>> print(json.dumps(json_cards, indent=2, sort_keys=True))

    [ 
      { 
        "__class__": "Card", 
        "rank": 2, 
        "suit": "\u2662" 
      }, 
      { 
        "__class__": "Card", 
        "rank": 13, 
        "suit": "\u2663" 
      }, 
      { 
        "__class__": "Card", 
        "rank": 7, 
        "suit": "\u2662" 
      }, 
      { 
        "__class__": "Card", 
        "rank": 6, 
        "suit": "\u2662" 
      }, 
      { 
        "__class__": "Card", 
        "rank": 7, 
        "suit": "\u2660" 
      } 
    ] 

我们已经使用deck.deal(5)从牌组中再处理五张牌。表达式list(card.to_json() for card in deck.deal(5))将使用每个Card对象的to_json()方法来发出该对象的小字典表示。然后将字典结构列表序列化为 JSON 符号。sort_keys=True选项便于创建可重复的测试用例。对于 RESTful web 服务,通常不需要它。

怎么做。。。

  1. 导入所需的模块和对象。我们将使用HTTPStatus类,因为它定义了常用的 HTTP 状态码。json模块需要生成 JSON 响应。我们还将使用os模块初始化一个随机数种子:

            from http import HTTPStatus 
            import json 
            import os 
            import random 
  2. 导入或定义基础类CardDeck。一般来说,最好将它们定义为一个单独的模块。基本特性应该存在并在 web 服务环境之外进行测试。其想法是,web 服务应该包装现有的工作软件。

  3. Create objects that are shared by all sessions. The value of deck is a module global variable:

            random.seed(os.environ.get('DEAL_APP_SEED')) 
            deck = Deck() 

    我们依靠os模块来检查环境变量。如果定义了环境变量DEAL_APP_SEED,我们将使用字符串值为随机数生成器种子。否则,我们将依赖random模块的内置随机化功能。

  4. Define the target WSGI application as a function. This function will respond to a request by dealing a hand of cards and then creating a JSON representation of the Card information:

            def deal_cards(environ, start_response): 
                global deck 
                hand_size = int(environ.get('HAND_SIZE', 5)) 
                cards = deck.deal(hand_size) 
                status = "{status.value} {status.phrase}".format(
                 status=HTTPStatus.OK) 
                headers = [('Content-Type', 'application/json;charset=utf-8')] 
                start_response(status, headers) 
                json_cards = list(card.to_json() for card in cards) 
                return [json.dumps(json_cards, indent=2).encode('utf-8')] 

    deal_cards()功能处理deck中的下一组卡。操作系统环境可以定义一个HAND_SIZE环境变量来更改交易的规模。全局deck对象用于执行相关处理。

    响应的状态行是一个字符串,其 HTTP 状态的数值和短语为OK。后面可以是标题。本例包括向客户端提供信息的Content-Type头;内容是一个 JSON 文档,该文档的字节使用utf-8编码。最后,文档本身就是这个函数的返回值。

  5. For demonstration and debugging purposes, it's helpful to build a server that runs the WSGI application. We'll use the wsgiref module's server. There are good servers defined in Werkzeug. Servers such as GUnicorn are even better:

            from wsgiref.simple_server import make_server 
            httpd = make_server('', 8080, deal_cards) 
            httpd.serve_forever() 

    服务器运行后,我们可以打开浏览器查看http://localhost:8080/。这将返回一批五张卡片。每次刷新时,我们都会得到一批不同的卡片。

这是因为在浏览器中输入 URL 会执行一个带有最小标题集的GET请求。因为我们的 WSGI 应用程序不需要任何特定的头,并且响应任何 HTTP 方法,所以它将返回一个结果。

结果是一个 JSON 文档,表示当前牌组中的五张牌。每张卡片都有一个类名ranksuit

    [ 
      { 
        "__class__": "Card", 
        "suit": "\u2663", 
        "rank": 6 
      }, 
      { 
        "__class__": "Card", 
        "suit": "\u2662", 
        "rank": 8 
      }, 
      { 
        "__class__": "Card", 
        "suit": "\u2660", 
        "rank": 8 
      }, 
      { 
        "__class__": "Card", 
        "suit": "\u2660", 
        "rank": 10 
      }, 
      { 
        "__class__": "Card", 
        "suit": "\u2663", 
        "rank": 11 
      } 
    ] 

我们可以用聪明的 JavaScript 程序创建网页来获取成批的卡片。这些网页和 JavaScript 程序可以为交易设置动画,并包含卡片图像的图形。

它是如何工作的。。。

WSGI 标准定义了 web 服务器和应用程序之间的接口。这是基于 Apache HTTPD公共网关接口CGI)的。CGI 设计用于运行 shell 脚本或单独的二进制文件。WSGI 是对这一传统概念的增强。

WSGI 标准使用各种信息定义了环境字典:

  • 字典中的许多键反映了经过初步解析和数据转换后的请求。

    • REQUEST_METHOD:HTTP 请求方式,如GETPOST
    • SCRIPT_NAME:请求 URL 路径的初始部分。这通常被视为一个整体应用程序对象或函数。
    • PATH_INFO:请求 URL 路径的剩余部分,指定资源的位置。在本例中,不执行路径解析。
    • QUERY_STRING:在?之后的请求 URL 部分,如果有的话:
    • CONTENT_TYPE:HTTP 请求中任意内容类型头值的内容。
    • CONTENT_LENGTH:HTTP 请求中任意内容长度头值的内容。
    • SERVER_NAMESERVER_PORT:来自请求的服务器名称和端口号。
    • SERVER_PROTOCOL:客户端用于发送请求的协议版本。通常,这类似于HTTP/1.0HTTP/1.1
  • The HTTP headers : These will have keys that start with HTTP_ and contain the header name in all uppercase letters.

    通常,请求的内容不是从服务器创建有意义响应所需的唯一数据。通常,需要额外的信息。该信息通常包括两种其他类型的数据:

  • OS 环境:服务启动时的环境变量提供服务器的配置细节。这可以提供包含静态内容的目录的路径。它可以提供用于验证用户的信息。

  • WSGI 服务器上下文:这些键以wsgi.开头,并且总是小写。这些值包括关于遵守 WSGI 标准的服务器的内部状态的一些附加信息。有两个特别有趣的对象用于上传文件和日志记录支持:

    • wsgi.input:是一个类似文件的对象。由此,可以读取 HTTP 请求正文字节。这通常必须根据Content-Type报头进行解码。
    • wsgi.errors:类似文件的对象,可以写入错误输出。这是服务器的日志。

WSGI 函数的返回值可以是序列对象或 iterable。返回一个 iterable 是一个非常大的文档可以分块构建并通过一些较小的缓冲区下载的方式。

此示例 WSGI 应用程序不检查请求路径。任何路径都可以用来取回一手牌。更复杂的应用程序可能会解析路径,以确定有关被请求手的大小或处理手的牌组的大小的信息。

还有更多。。。

web 服务可以可视化为许多公共部分,这些公共部分连接在一起形成嵌套的外壳或层。WSGI 应用程序的统一接口鼓励这种可重用特性的组合。

有许多常用技术可用于保护和生成动态内容。这些技术是 web 服务应用程序的交叉关注点。我们有以下几种选择:

  • 我们可以在一个应用程序中编写许多if语句
  • 我们可以提取公共编程并创建一个公共包装器,将安全问题与内容的构造分离开来

包装器只是另一个不直接产生结果的 WSGI 应用程序。相反,包装器将生成结果的工作交给另一个 WSGI 应用程序。

例如,我们可能需要一个包装器来确认 JSON 响应是预期的。此包装器将区分以人为中心的 HTML 请求和以应用程序为中心的 JSON 请求。

为了使应用程序更加灵活,使用可调用对象而不是简单的函数通常很有帮助。这样做可以使各种应用程序和包装器的配置更加灵活。我们将把 JSON 过滤器的思想与可调用对象结合起来。

此对象的轮廓如下所示:

    class JSON_Filter: 
        def __init__(self, json_app): 
            self.json_app = json_app 
        def __call__(self, environ, start_response): 
            return json_app(environ, start_response) 

我们将通过提供另一个应用程序从此类定义创建一个可调用对象。另一个应用程序json_app将被此可调用对象包装。

我们将这样使用它:

    json_wrapper = JSON_Filter(deal_cards) 

这将包装原始的deal_cards()WSGI 应用程序。我们现在可以将复合json_wrapper对象用作 WSGI 应用程序。当服务器调用json_wrapper(environ, start_response)时,将调用对象的__call__()方法,在本例中,该方法将请求传递给deal_cards()函数。

下面是更完整的包装器应用程序。此包装器将检查 HTTP Accept 标头中的字符"json"。它还将检查?$format=json的查询字符串,以查看是否发出了 JSON 格式的请求。此类的实例可以配置为引用deal_cards()WSGI 应用程序:

    from urllib.parse import parse_qs 
    class JSON_Filter: 
        def __init__(self, json_app): 
            self.json_app = json_app 
        def __call__(self, environ, start_response): 
            if 'HTTP_ACCEPT' in environ: 
                if 'json' in environ['HTTP_ACCEPT']: 
                    environ['$format'] = 'json' 
                    return self.json_app(environ, start_response) 
            decoded_query = parse_qs(environ['QUERY_STRING']) 
            if '$format' in decoded_query: 
                if decoded_query['$format'][0].lower() == 'json': 
                    environ['$format'] = 'json' 
                    return self.json_app(environ, start_response) 
            status = "{status.value}         {status.phrase}".format(status=HTTPStatus.BAD_REQUEST) 
            headers = [('Content-Type', 'text/plain;charset=utf-8')] 
            start_response(status, headers) 
            return ["Request doesn't include ?$format=json or Accept     header".encode('utf-8')] 

__call__()方法检查 Accept 头和查询字符串。如果字符串json出现在 HTTP Accept 头中的任何位置,则调用给定的应用程序。将更新环境以包含此包装器使用的头信息。

如果 HTTP Accept 标头不存在或不需要 JSON 响应,则检查查询字符串。这种回退可能很有帮助,因为很难更改浏览器发送的标题;使用查询字符串是一种浏览器友好的选择,可以替代 Accept 标头。parse_qs()函数将查询字符串分解为键和值的字典。如果查询字符串有$format作为键,则检查该值是否包含'json'。如果这是真的,则使用查询字符串中的格式信息更新环境。

在这两种情况下,调用包装的应用程序时都会修改环境。正在包装的函数只需要检查 WSGI 环境中的格式信息。此包装器对象返回响应,无需进一步修改。

如果请求没有请求 JSON,则会发送一个带有简单文本消息的400 BAD REQUEST响应。这将为为何该查询不可接受提供一些指导。

我们使用这个JSON_Filter包装类定义如下:

    json_wrapper = JSON_Filter(deal_cards) 
    httpd = make_server('', 8080, json_wrapper) 

我们没有从deal_cards()创建服务器,而是创建了一个引用deal_cards()函数的JSON_Filter类实例。这将与前面显示的版本几乎完全相同。重要的区别在于,这需要一个 Accept 头或类似于以下内容的 URL:http://localhost:8080/?$format=json

提示

这个例子有一个微妙的语义问题。GET方法更改服务器的状态。这通常是个坏主意。

因为我们在看浏览器,所以很难解决问题。这里没有太多的调试支持。这意味着print()函数以及日志消息对于调试是必不可少的。由于 WSGI 的工作方式,必须打印到sys.stderr。使用 Flask 更容易,我们将在中展示如何使用用于 RESTful API 的 Flask 框架配方。

HTTP 支持多种方式,包括GETPOSTPUTDELETE。通常,将这些方法映射到数据库CRUD操作是明智的;使用POST完成创建,使用GET完成检索,使用PUT完成更新,并删除到DELETE的地图。这意味着GET操作不会改变数据库的状态。

这导致了 web 服务的GET操作应该是幂等的想法。没有任何其他POSTPUTDELETE操作的一系列GET操作每次应返回相同的结果。在此配方中,每个GET返回不同的结果。这是使用GET发牌的语义问题。

为了演示基础知识,区别很小。在大型和更复杂的 web 应用程序中,区分是一个重要的考虑因素。由于 deal 服务不是幂等的,因此有一种观点认为应该使用POST方法访问它。

为了便于使用浏览器进行探索,我们避免了在 WSGI 应用程序中检查方法。

另见

将 Flask 框架用于 RESTful API

使用 WSGI实现 web 服务的配方中,我们研究了使用 Python 标准库中可用的 WSGI 组件构建 RESTful API 和微服务。这将导致大量编程来处理许多常见情况。

我们如何简化所有常见的 web 应用程序编程并消除样板代码?

准备好了吗

首先,我们需要将 Flask 框架添加到我们的环境中。这通常依赖于使用pip安装最新版本的烧瓶和其他相关项目itsdangerousJinja2clickMarkupSafeWerkzeug

安装过程如下所示:

slott$ sudo pip3.5 install flask

Password:

Collecting flask

Downloading Flask-0.11.1-py2.py3-none-any.whl (80kB)

100% |████████████████████████████████| 81kB 3.6MB/s

Collecting itsdangerous>=0.21 (from flask)

 Downloading itsdangerous-0.24.tar.gz (46kB)

100% |████████████████████████████████| 51kB 8.6MB/s

Requirement already satisfied (use --upgrade to upgrade): Jinja2>=2.4 in /Library/Frameworks/Python.framework/Versions/3.5/lib/python3.5/site-packages (from flask)

Collecting click>=2.0 (from flask)

Downloading click-6.6.tar.gz (283kB)

100% |████████████████████████████████| 286kB 4.0MB/s

Collecting Werkzeug>=0.7 (from flask)

Downloading Werkzeug-0.11.10-py2.py3-none-any.whl (306kB)

100% |████████████████████████████████| 307kB 3.8MB/s

Requirement already satisfied (use --upgrade to upgrade): MarkupSafe in /Library/Frameworks/Python.framework/Versions/3.5/lib/python3.5/site-packages (from Jinja2>=2.4->flask)

Installing collected packages: itsdangerous, click, Werkzeug, flask

 Running setup.py install for itsdangerous ... done

Running setup.py install for click ... done

Successfully installed Werkzeug-0.11.10 click-6.6 flask-0.11.1 itsdangerous-0.24

我们可以看到Jinja2MarkupSafe已经安装。缺失的元素由pip定位、下载并安装。Windows 用户不会使用sudo命令。

Flask 允许我们大大简化 web 服务应用程序。我们可以创建一个具有单独函数的模块,而不是创建一个大型且可能复杂的 WSGI 兼容函数或可调用对象。每个函数都可以处理 URL 路径的特定模式。

我们将看一看我们在用 WSGI 方法实现 web 服务的中使用的核心卡交易功能。Card类定义了一张简单的扑克牌。Deck类定义了一副牌。

因为 Flask 为我们处理 URL 解析的细节,所以我们可以很容易地创建一个更复杂的 web 服务。我们将定义如下所示的路径:

/dealer/hand/?cards=5

此路线包含三条重要信息:

  • 路径的第一部分/dealer/是整个 web 服务。
  • 路径的下一部分hand/是一个特定的资源,一手牌。
  • 查询字符串?cards=5定义查询的 cards 参数。这是请求的手的大小。这限制在 1 到 52 张卡的范围内。超出范围的值将获得400状态代码,因为查询无效。

怎么做。。。

  1. Import some core definitions from the flask package. The Flask class defines the overall application. The request object holds the current web request:

            from flask import Flask, request, jsonify, abort 
            from http import HTTPStatus 

    jsonify()函数将从 Flask view 函数返回 JSON 格式的对象。abort()函数返回 HTTP 错误状态并结束请求处理。

  2. Import the underlying classes, Card and Deck . Ideally, these are imported from a separate module. It should be possible to test all of the features outside the web services environment:

            from ch12_r01 import Card, Deck 

    为了正确洗牌,我们还需要random模块:

            import random 
  3. 创建Flask对象。这是整个 web 服务应用程序。我们将调用 Flask 应用程序'dealer',并将该对象分配给一个全局变量dealer

            dealer = Flask('dealer') 
  4. Create any objects used throughout the application. These can be assigned to the Flask object, dealer , as attributes. Be sure to create a unique name that doesn't conflict with any of Flask's internal attributes. The alternative is to use module globals.

    有状态全局对象必须能够在多线程环境中工作,或者必须显式禁用线程:

            import os 
            random.seed(os.environ.get('DEAL_APP_SEED')) 
            deck = Deck() 

    对于这个配方,Deck类的实现不是线程安全的,因此我们将依赖于单线程服务器。deal()方法应该使用threading模块中的Lock类来定义独占锁,以确保对并发线程的正确操作。

  5. 定义执行特定请求的视图函数的路由(URL 模式)。这是一个装饰器,直接放在函数前面。它将函数绑定到烧瓶应用程序:

            @dealer.route('/dealer/hand/') 
  6. Define the view function, which retrieves data or updates the application state. In this example, the function does both:

            def deal(): 
                try: 
                    hand_size = int(request.args.get('cards', 5)) 
                    assert 1 <= hand_size < 53 
                except Exception as ex: 
                    abort(HTTPStatus.BAD_REQUEST) 
                cards = deck.deal(hand_size) 
                response = jsonify([card.to_json() for card in cards]) 
                return response 

    Flask 解析 URL 中的?后面的字符串并创建查询字符串request.args值。客户端应用程序或浏览器可以使用查询字符串(如?cards=13)设置此值。这将为桥牌交易 13 手牌。

    如果查询字符串中的手工大小值不合适,abort()函数将结束处理并返回 HTTP 状态码400。这表明该请求不可接受。这是一个最小的响应,没有更详细的内容。

    应用程序的实际工作是一条语句,cards = dealer.deck.deal(hand_size)。这里的想法是将现有功能包装到 web 框架中。这些特性可以在没有 web 应用程序的情况下进行测试。

    响应由jsonify()函数处理:这将创建一个响应对象。响应的主体将是一个用 JSON 表示法表示的 Python 对象。如果我们需要在响应中添加标题,我们可以更新response.headers以包含其他信息。

  7. Define the main program which runs the server:

            if __name__ == "__main__": 
                dealer.run(use_reloader=True, threaded=False, debug=True) 

    我们包括了debug=True选项,以在浏览器以及 Flask 日志文件中提供丰富的调试信息。服务器运行后,我们可以打开浏览器查看http://localhost:5000/。这将返回一批五张卡片。每次刷新时,我们都会得到一批不同的卡片。

这是因为在浏览器中输入 URL 会执行一个带有最小标题集的GET请求。因为我们的 WSGI 应用程序不需要任何特定的头,并且响应所有 HTTP 方法,所以它将返回一个结果。

结果是一个包含五张卡片的 JSON 文档。每张卡片由一个类名ranksuit信息表示:

    [ 
      { 
        "__class__": "Card", 
        "suit": "\u2663", 
        "rank": 6 
      }, 
      { 
        "__class__": "Card", 
        "suit": "\u2662", 
        "rank": 8 
      }, 
      { 
        "__class__": "Card", 
        "suit": "\u2660", 
        "rank": 8 
      }, 
      { 
        "__class__": "Card", 
        "suit": "\u2660", 
        "rank": 10 
      }, 
      { 
        "__class__": "Card", 
        "suit": "\u2663", 
        "rank": 11 
      } 
    ] 

要查看五张以上的卡片,可以修改 URL。例如,这将返回一个桥牌手:http://127.0.0.1:5000/dealer/hand/?cards=13

它是如何工作的。。。

Flask 应用程序由一个具有多个单独视图函数的应用程序对象组成。在这个配方中,我们创建了一个视图函数deal()。应用程序通常具有许多功能。一个复杂的网站可能有许多应用程序,每个应用程序都有许多功能。

路由是 URL 模式和视图函数之间的映射。这样就可以创建包含 view 函数使用的参数的路由。

@flask.route装饰器是用于将每个路由和视图功能添加到整个 Flask 实例中的技术。视图功能基于路由模式绑定到整个应用程序中。

Flask对象的run()方法执行以下类型的处理。这并不是 Flask 的工作原理,但它提供了各种步骤的大致轮廓:

  • 它等待 HTTP 请求。根据 WSGI 标准,请求以字典的形式到达。有关 WSGI 的更多信息,请参阅使用 WSGI配方实现 web 服务。
  • 它从 WSGI 环境创建一个烧瓶Request对象。request对象拥有来自请求的所有信息,包括所有 URL 元素、查询字符串元素和任何附加文档。
  • Flask 然后检查各种路由,寻找与请求路径匹配的路由。
    • 如果找到路由,则执行查看功能。该函数创建一个Response对象。这是视图函数的返回值。
    • 如果未找到路由,则自动发送404 NOT FOUND响应。
  • 遵循 WSGI 模式准备状态和头以开始发送响应。然后,从 view 函数返回的Response对象作为字节流提供。

Flask 应用程序可以包含许多方法,使提供 web 服务变得非常容易。Flask 将其中一些方法公开为隐式绑定到请求或会话的独立函数。这使得编写视图函数稍微简单一些。

还有更多。。。

在使用 WSGI 实现 web 服务的配方中,我们将应用程序包装在一个通用测试中,该测试确认请求具有两个属性之一。我们使用了以下两条规则:

  • 需要 JSON 的接受头
  • 包含$format=json的查询字符串

如果我们正在编写一个复杂的 RESTful 应用程序服务器,我们通常希望将这种测试应用于所有视图函数。我们不希望重复此测试的代码。

当然,我们可以将实现 web 服务的 WSGI配方中的 WSGI 解决方案与 Flask 应用程序结合起来,构建一个复合应用程序。我们也可以完全在烧瓶内完成这项工作。纯烧瓶溶液比 WSGI 溶液简单一点,因此是理想的。

我们见过烧瓶装饰师。Flask 还有许多其他装饰器,可用于定义请求和响应处理的各个阶段。为了对传入请求应用测试,我们可以使用@flask.before_request装饰器。在处理请求之前,将调用具有此装饰的所有函数:

    @dealer.before_request 
    def check_json(): 
        if 'json' in request.headers.get('Accept'): 
        return 
        if 'json' == request.args.get('$format'): 
            return 
        return abort(HTTPStatus.BAD_REQUEST) 

@flask.before_request修饰符未能返回值(或返回None时,处理将继续。将检查路线,并评估查看功能。

在本例中,如果 Accept 头包含json$format查询参数为json,则函数返回None。这意味着将找到普通视图功能来处理请求。

@flask.before_request装饰器返回值时,这是最终结果,处理停止。在本例中,check_json()函数可能返回abort()响应,这将停止处理。abort()响应成为烧瓶应用的最终响应。这使得返回错误消息非常容易。

现在,我们可以使用浏览器的地址窗口输入 URL,如下所示:

http://127.0.0.1:5000/dealer/hand/?cards=13&$format=json

这将返回一个 13 卡片手,请求现在以 JSON 格式显式请求结果。为$format尝试其他值以及完全省略$format键都是有益的。

提示

这个例子有一个微妙的语义问题。GET方法更改服务器的状态。这通常是个坏主意。

HTTP 支持许多并行数据库 CRUD 操作的方法。创建通过POST完成,检索通过GET完成,更新通过PUT完成,删除到DELETE的地图。

然后,这个想法引出了 web 服务GET操作应该是幂等的想法。没有任何其他POSTPUTDELETE的一系列GET操作每次都应返回相同的结果。在本例中,每个GET返回不同的结果。因为交易服务不是幂等的,所以应该使用POST方法访问它。

为了便于使用浏览器进行探索,我们避免了在 Flask 路由中检查方法。理想情况下,路由装饰器应如下所示:

    @dealer.route('/dealer/hand/', methods=['POST']) 

这样做会使使用浏览器查看服务是否正常工作变得困难。在使用 urllib方法发出 REST 请求中,我们将看到创建一个客户端,并切换到使用POST方法。

另见

解析请求中的查询字符串

URL 是一个复杂的对象。它至少包含六条独立的信息。可以通过可选元素包含更多信息。

http://127.0.0.1:5000/dealer/hand/?cards=13&$format=json这样的 URL 有几个字段:

  • http是方案。https用于使用加密套接字的安全连接。
  • 127.0.0.1可以被称为权威,尽管网络定位更常用。这个特定的 IP 地址意味着本地主机,是一种到本地主机的环回。名称 localhost 映射到此 IP 地址。
  • 5000是端口号,是管理局的一部分。
  • /dealer/hand/是指向资源的路径。
  • cards=13&$format=json是一个查询字符串,由?字符与路径分隔。

查询字符串可能相当复杂。虽然不是官方标准,但查询字符串可能(也是常见的)具有重复键。以下查询字符串有效,但可能令人困惑:

    ?cards=13&cards=5 

我们重复了cards键。网络服务将提供 13 张牌和 5 张牌。

[作者不知道有任何手牌大小不一的纸牌游戏。缺乏一个好的用户故事使得这个例子有些做作。

重复一个键的能力打破了 URL 查询字符串和内置 Python 字典之间简单映射的可能性。此问题有几种可能的解决方案:

  • 字典中的每个键必须与包含所有值的list相关联。这对于最常见的不重复按键的情况来说是很尴尬的;每个列表只有一个项目。此解决方案通过urllib.parse中的parse_qs()实现。
  • 每个键只保存一次,保留第一个(或最后一个)值,删除其他值。这太糟糕了。
  • 未使用的词典。相反,可以将查询字符串解析为一组*(键、值**对。这也允许复制密钥。对于具有唯一键的常见情况,可以将列表转换为字典。对于不常见的情况,可以通过其他方式处理重复的密钥。这是由urllib.parse中的parse_qsl()实现的。*

*有没有更好的方法来处理查询字符串?我们是否可以拥有一个更复杂的结构,其行为类似于普通情况下具有单个值的字典,而对于字段键重复且具有多个值的罕见情况,我们是否可以拥有一个更复杂的对象?

准备好了吗

这取决于另一个项目Werkzeug。当我们使用pip安装烧瓶时,需求将导致pip也安装 Werkzeug 工具包。Werkzeug 有一个数据结构,它提供了处理查询字符串的极好方法。

我们将使用 RESTful API 的 Flask 框架配方修改中的示例,以使用更复杂的查询字符串。我们将添加第二条处理多手交易的路线。每只手的大小将在允许重复键的查询字符串中指定。

怎么做。。。

  1. 开始,为 RESTful API配方使用 Flask 框架。我们将向现有 web 应用程序添加一个新的视图函数。

  2. 定义执行特定请求的视图函数的路由(URL 模式)。这是一个装饰器,直接放在函数前面。它将函数绑定到烧瓶应用程序:

            @dealer.route('/dealer/hands/') 
  3. 定义一个视图函数,用于响应发送到特定路由的请求:

            def multi_hand(): 
  4. 在 view 函数中,使用get()方法提取唯一键的值,或使用适用于内置 dict 类型的普通[]语法。对于列表只有一个元素的常见情况,这将返回单个值,而不会使列表复杂化。

  5. For repeated keys, use the getlist() method. This returns each of the values as a list. Here's a view function that looks for a query string such as ?card=5&card=5 to deal two five-card hands:

            try: 
                hand_sizes = request.args.getlist('cards', type=int) 
                if len(hand_sizes) == 0: 
                    hand_sizes = [13,13,13,13] 
                assert all(1 <= hand_size < 53 for hand_size in hand_sizes) 
            except Exception as ex: 
                dealer.logger.exception(ex) 
                abort(HTTPStatus.BAD_REQUEST) 
    
            hands = [deck.deal(hand_size) for hand_size in hand_sizes] 
            response = jsonify( 
                [ 
                    {'hand':i, 
                     'cards':[card.to_json() for card in hand] 
                    } for i, hand in enumerate(hands) 
                ] 
            ) 
            return response 

    此函数将从查询字符串中获取所有的cards键。如果这些值都是整数,并且每个值都在 1 到 52(包括 1 到 52)之间,那么这些值是有效的,view 函数将返回一个结果。如果查询中没有cards键值,则发 4 手 13 张牌。

    响应将是每个手的 JSON 表示,作为一个小字典,带有两个键:一个手 ID 和来自手的卡片。

  6. 定义运行服务器的主程序:

            if __name__ == "__main__": 
                dealer.run(use_reloader=True, threaded=False) 

服务器运行后,我们可以打开浏览器查看此 URL:

http://localhost:5000/?cards=5&cards=5&$format=json

结果是一个 JSON 文档,有两手五张卡片。我们省略了一些细节以强调响应的结构:

    [ 
      { 
        "cards": [ 
          { 
            "__class__": "Card", 
            "rank": 11, 
            "suit": "\u2660" 
          }, 
          { 
            "__class__": "Card", 
            "rank": 8, 
            "suit": "\u2662" 
          }, 
          ... 
        ], 
        "hand": 0 
      }, 
      { 
        "cards": [ 
          { 
            "__class__": "Card", 
            "rank": 3, 
            "suit": "\u2663" 
          }, 
          { 
            "__class__": "Card", 
            "rank": 9, 
            "suit": "\u2660" 
          }, 
          ... 
        ], 
        "hand": 1 
      } 
    ] 

因为 web 服务解析查询字符串,所以向查询字符串添加更复杂的手尺寸是很简单的。该示例包括基于使用 Flask 框架的 RESTful API 配方的$format=json

如果实现了@dealer.before_request函数check_json()来检查 JSON,则需要$format。如果未实现@dealer.before_request函数check_json(),则忽略查询字符串中的附加信息。

它是如何工作的。。。

Werkzeug-Multidict类是一个非常方便的数据结构。这是内置词典的扩展。它允许给定键具有多个不同的值。

我们可以使用collections模块中的defaultdict类来构建类似的东西。定义为defaultdict(list)。这个定义的问题是,每个键的值都是一个列表,即使列表中只有一个项作为值。

Multidict类提供的优势是get()方法的变化。当一个键有多个副本时,get()方法返回第一个值,或者当该键只出现一次时返回唯一的值。这也有一个默认参数。此方法与内置dict类的方法并行。

然而,getlist()方法返回给定键的所有值的列表。此方法是Multidict类独有的。我们可以使用此方法解析更复杂的查询字符串。

用于验证查询字符串的一种常见技术是在验证项时弹出它们。这是通过pop()poplist()方法完成的。这些将从Multidict类中删除密钥。如果在检查所有有效密钥后仍保留任何密钥,则这些额外的密钥将被视为语法错误,web 请求将被拒绝,并使用abort(HTTPStatus.BAD_REQUEST)

还有更多。。。

查询字符串使用相对简单的语法规则。有一个或多个键值对使用=作为键和值之间的标点。每对之间的分隔符为&字符。由于解析 URL 时其他字符的含义,还有一条规则很重要,即必须对键和值进行编码。

URL 编码规则要求用 HTML 实体替换某些字符。这种技术称为百分比编码。这意味着当我们将&放入查询字符串的值中时,必须将其编码为%26,下面是一个显示这种编码的示例:

>>> from urllib.parse import urlencode 
>>> urlencode( {'n':355,'d':113} ) 
'n=355&d=113' 
>>> urlencode( {'n':355,'d':113,'note':'this&that'} ) 
'n=355&d=113&note=this%26that'

this&that被编码为this%26that

这里有一个简短的字符列表,必须应用%-编码规则。来源于RFC 3986,参见第 2.2 节预留字符。该列表包括以下字符:

! * ' ( ) ; : @ & = + $ , / ? # [ ] % 

通常,与网页关联的 JavaScript 代码将处理编码查询字符串。如果我们用 Python 编写 API 客户机,我们需要使用urlencode()函数对查询字符串进行正确编码。Flask 为我们自动处理解码。

查询字符串有一个实际的大小限制。例如,ApacheHttpd 有一个默认值为8190LimitRequestLine配置参数。这将整个 URL 限制在此大小。

在 OData 规范中(http://docs.oasis-open.org/odata/odata/v4.0/ ),查询选项建议使用几种值。此规范建议我们的 web 服务应支持以下类型的查询选项:

  • 对于标识实体或实体集合的 URL,可以使用$expand$select选项。展开结果意味着查询将提供更多详细信息。select 查询将对集合施加其他条件。
  • 标识集合的 URL 应支持$filter$search$orderby$count$skip$top选项。这些对于返回单个项目的 URL 来说没有意义。$filter$search选项接受查找数据的复杂条件。$orderby选项定义了对结果施加的特定顺序。

$count选项从根本上改变了查询。它将返回项目的计数,而不是项目本身。

$top$skip选项用于翻页数据。如果计数较大,通常使用$top选项将结果限制为网页上显示的特定数字。$skip选项的值决定将显示哪一页数据。例如,$top=20$skip=40将是跳过 40 后排名前 20 的结果的第 3 页。

通常,所有 URL 都应该支持$format选项来指定结果的格式。我们一直在关注 JSON,但更复杂的服务可能会提供 CSV 输出,甚至 XML。

另见

  • 请参阅使用 Flask 框架实现 RESTful API配方,了解使用 Flask 实现 web 服务的基础知识。
  • 使用 urllib方法生成 REST 请求中,我们将了解如何编写一个可以准备复杂查询字符串的客户端应用程序。

使用 urllib 进行 REST 请求

web 应用程序有两个基本部分:

  • 客户端:可以是用户的浏览器,也可以是移动设备应用。在某些情况下,web 服务器可能是其他 web 服务器的客户端。
  • 服务器:在使用 WSGI实现 web 服务的过程中,使用 RESTful API 的 Flask 框架解析请求配方中的查询字符串,以及其他配方,例如解析 JSON 请求实现 web 服务的身份验证。

基于浏览器的客户端通常使用 JavaScript 编写。移动应用程序使用多种语言编写,重点是 Android 设备的 Java,iOS 设备的 Objective-C 和 Swift。

有几个用户案例涉及用 Python 编写的 RESTful API 客户机。我们如何创建一个作为 RESTfulWeb 服务客户端的 Python 程序?

准备好了吗

我们将假设我们有一个基于的 web 服务器,该服务器使用 WSGI实现 web 服务,使用 RESTful API 的 Flask 框架,或者解析请求配方中的查询字符串。我们可以通过以下方式为此服务器的行为编写正式规范:

    { 
      "swagger": "2.0", 
      "info": { 
        "title": "dealer", 
        "version": "1.0" 
      }, 
      "schemes": ["http"], 
      "host": "127.0.0.1:5000", 
      "basePath": "/dealer", 
      "consumes": ["application/json"], 
      "produces": ["application/json"], 
      "paths": { 
        "/hands": { 
          "get": { 
            "parameters": [ 
              { 
                "name": "cards", 
                "in": "query", 
                "description": "number of cards in each hand", 
                "type": "array", 
                "items": {"type": "integer"}, 
                "collectionFormat": "multi", 
                "default": [13, 13, 13, 13] 
              } 
            ], 
            "responses": { 
              "200": { 
                "description":  
                "one hand of cards for each `hand` value in the query string" 
              } 
            } 
          } 
        }, 
        "/hand": { 
          "get": { 
            "parameters": [ 
              { 
                "name": "cards", 
                "in": "query", 
                "type": "integer", 
                "default": 5 
              } 
            ], 
            "responses": { 
              "200": { 
                "description":  
                "One hand of cards with a size given by the `hand` value in the query string" 
              } 
            } 
          } 
        } 
      } 
    } 

本文档为我们提供了一些关于如何使用 Python 的urllib模块使用这些服务的指导。它还描述了预期的响应应该是什么,为我们提供了如何处理响应的指导。

本规范中的某些字段定义了基本 URL。这三个字段尤其提供以下信息:

      "schemes": ["http"], 
      "host": "127.0.0.1:5000", 
      "basePath": "/dealer", 

producesconsumes字段提供有助于构建和验证 HTTP 头的信息。请求Content-Type头必须是服务器使用的多用途互联网邮件扩展MIME类型)。类似地,请求接受头必须指定服务器生成的 MIME 类型。在这两种情况下,我们都将提供application/json

详细的服务定义见本规范的paths部分。例如,/hands路径显示了如何请求多只手的详细信息。路径细节是basePath值的后缀。

当 HTTP 方法为get时,查询中提供参数。查询中的cards参数提供了整数张卡片,可以重复多次。

响应将至少包括所描述的响应。在这种情况下,HTTP 状态将为200,并且响应的主体具有最小的描述。可以为响应提供更正式的模式定义,我们将在本例中省略它。

怎么做。。。

  1. 导入所需的urllib组件。我们将发出 URL 请求,并构建更复杂的对象,例如查询字符串。我们需要urllib.requesturllib.parse模块来实现这两个功能。由于预期的响应是 JSON 格式的,json模块也会很有用:

            import urllib.request 
            import urllib.parse 
            import json 
  2. 定义将要使用的查询字符串。在这种情况下,所有的值都是固定的。在更复杂的应用程序中,有些可能是固定的,有些可能基于用户输入:

            query = {'hand': 5} 
  3. Use the query to build the pieces of the full URL:

            full_url = urllib.parse.ParseResult( 
                scheme="http", 
                netloc="127.0.0.1:5000", 
                path="/dealer" + "/hand/", 
                params=None, 
                query=urllib.parse.urlencode(query), 
                fragment=None 
            ) 

    在本例中,我们使用ParseResult对象来保存 URL 的相关部分。这个类对于缺少的项来说并不优雅,所以我们必须为 URL 中未被使用的部分提供显式的None值。

    我们可以在脚本中使用"http://127.0.0.1:5000/dealer/hand/?cards=5"。然而,这个压缩字符串很难更改。当发出请求时,它作为一个紧凑的消息是有用的,但是它不适合制作灵活、可维护和可测试的程序。

    使用这个长构造函数的优点是为 URL 的每个部分提供显式值。在更复杂的应用程序中,各个部分都是根据前面所示的 JSON Swagger 规范文档分析构建的:

  4. Build a final Request instance. We'll use the URL built from a variety of pieces. We'll explicitly provide an HTTP method (browsers tend to use GET  as a default). Also, we can provide explicit headers:

            request = urllib.request.Request( 
                url = urllib.parse.urlunparse(full_url), 
                method = "GET", 
                headers = { 
                    'Accept': 'application/json', 
                } 
            ) 

    我们提供了 HTTP Accept 头来声明将由服务器生成并由客户端接受的 MIME 类型结果。我们提供了 HTTPContent-Type头来说明服务器使用的请求,并由我们的客户端脚本提供。

  5. 打开上下文以处理响应。urlopen()函数发出请求,处理 HTTP 协议的所有复杂性。最后一个result对象可作为响应进行处理:

            with urllib.request.urlopen(request) as response: 
  6. Generally, there are three attributes of the response that are of particular interest:

            print(response.status) 
            print(response.headers) 
            print(json.loads(response.read().decode("utf-8"))) 

    status是最终状态代码。我们期望正常请求的 HTTP 状态为200headers包括响应的所有标题。例如,我们可能想检查response.headers['Content-Type']是否真的是application/json

    response.read()的值是从服务器下载的字节数。我们通常需要对这些字符进行解码以获得正确的 Unicode 字符。utf-8编码方案非常常见。我们可以使用json.loads()从 JSON 文档创建 Python 对象。

运行此操作时,我们将看到以下输出:

200 
Content-Type: application/json 
Content-Length: 367 
Server: Werkzeug/0.11.10 Python/3.5.1 
Date: Sat, 23 Jul 2016 19:46:35 GMT 

[{'suit': '♠', 'rank': 4, '__class__': 'Card'}, 
 {'suit': '♡', 'rank': 4, '__class__': 'Card'}, 
 {'suit': '♣', 'rank': 9, '__class__': 'Card'}, 
 {'suit': '♠', 'rank': 1, '__class__': 'Card'}, 
 {'suit': '♠', 'rank': 2, '__class__': 'Card'}]

首字母200是状态,表示一切正常。服务器提供了四个标头。最后,内部 Python 对象是一个小字典数组,它提供关于所发牌的信息。

要重建Card对象,我们需要使用稍微聪明一点的 JSON 解析器。参见第 9 章中的阅读 JSON 文档配方、输入/输出、物理格式和逻辑布局

它是如何工作的。。。

我们通过几个明确的步骤建立了请求:

  1. 查询数据从一个简单的字典开始,其中包含键和值。
  2. urlencode()函数将查询数据转换为查询字符串,并正确编码。
  3. URL 作为一个整体从一个ParseResult对象中的单个组件开始。这使得每一块都可见,并且可以改变。对于这个特定的 API,这些部分基本上是固定的。在其他 API 中,URL 的路径和查询部分可能都具有动态值。
  4. 请求作为一个整体是从 URL、方法和标题字典构建的。此示例未提供单独的文档作为请求主体。如果发送了一个复杂的文档,或者上传了一个文件,也可以通过向Request对象提供详细信息来完成。

简单的应用程序不需要分步程序集。在简单的情况下,URL 的文本字符串值可能是可以接受的。在另一个极端,更复杂的应用程序可能打印出中间结果作为调试辅助,以确保正确构造请求。

这样详细说明细节的另一个好处是为单元测试提供了方便的途径。详见第 11 章测试。我们通常可以将 web 客户机分解为请求构建和请求处理。可以仔细测试请求建筑,以确保所有元素都设置正确。可以使用不涉及到远程服务器的实时连接的虚拟结果来测试请求处理。

还有更多。。。

用户身份验证通常是 web 服务的一个重要部分。对于强调用户交互的基于 HTML 的网站,人们希望服务器通过会话理解一个长期运行的事务序列。此人将对自己进行一次身份验证(通常使用用户名和密码),服务器将使用此信息,直到此人注销或会话过期。

对于 RESTful web 服务,很少有会话的概念。每个请求都是单独处理的,服务器不需要维护复杂的长时间运行的事务状态。这个责任转移到客户端应用程序。客户需要提出适当的请求,以构建一个可以作为单个事务呈现的复杂文档。

对于 RESTful API,每个请求可能包括身份验证信息。我们将在为 web 服务实现身份验证配方中详细介绍这一点。现在,我们将通过标题提供更多细节。这将非常适合我们的 RESTful 客户端脚本。

向 web 服务器提供身份验证信息的方式有多种:

  • 一些服务使用 HTTPAuthorization头。当与基本机制一起使用时,客户机可以为每个请求提供用户名和密码。
  • 一些服务将发明一个全新的标题,其名称如 API 密钥。此标头的值可能是一个复杂字符串,其中包含有关请求者的编码信息。
  • 一些服务会发明一个名称为X-Auth-Token的标题。这可用于多步骤操作,其中用户名和密码凭据作为初始请求的一部分发送。结果将包括可用于后续 API 请求的字符串值(令牌)。通常,令牌的有效期很短,必须续订。

通常,这些方法需要安全套接字层SSL协议)。这可作为https方案提供。为了处理 SSL 协议,服务器(有时是客户端)必须具有适当的证书。它们用作客户端和服务器之间协商的一部分,以设置加密套接字对。

所有这些身份验证技术都有一个共同特点,即它们依赖于在报头中发送附加信息。它们在使用哪个标题和发送什么信息方面略有不同。在最简单的情况下,我们可能有如下内容:

    request = urllib.request.Request( 
        url = urllib.parse.urlunparse(full_url), 
        method = "GET", 
        headers = { 
            'Accept': 'application/json', 
            'X-Authentication': 'seekrit password', 
        } 
    ) 

这个假设的请求是针对需要在X-Authentication头中提供密码的 web 服务的。在为 web 服务实现身份验证配方中,我们将向 web 服务器添加身份验证功能。

OpenAPI(招摇过市)规范

许多服务器将以固定的标准 URL 路径/swagger.json明确提供规范作为文件。OpenAPI 规范以前被称为招摇过市,提供接口的文件名反映了这一历史。

如果提供,我们可以通过以下方式获得网站的 OpenAPI 规范:

    swagger_request = urllib.request.Request( 
        url = 'http://127.0.0.1:5000/dealer/swagger.json', 
        method = "GET", 
        headers = { 
            'Accept': 'application/json', 
        } 
    ) 

    from pprint import pprint 
    with urllib.request.urlopen(swagger_request) as response: 
        swagger = json.loads(response.read().decode("utf-8")) 
        pprint(swagger) 

一旦我们有了规范,我们就可以使用它来获取服务或资源的详细信息。我们可以使用规范中的技术信息来构建 URL、查询字符串和标题。

向服务器添加招摇

对于我们的小型演示服务器,需要一个额外的视图函数来提供 OpenAPI Swagger 规范。我们可以更新ch12_r03.py模块以响应swagger.json请求。

有几种方法可以处理此重要信息:

  1. A separate, static file. That's what's shown in this recipe. It's a very simple way to provide the required content.

    我们可以添加一个查看函数,它将发送一个文件。当然,我们还需要将规范放入命名文件中:

            from flask import send_file 
            @dealer.route('/dealer/swagger.json') 
            def swagger(): 
                response = send_file('swagger.json', mimetype='application/json') 
                return response 

    这种方法的缺点是规范与实现模块是分开的。

  2. Embed the specification as a large blob of text in the module. We could, for example, provide the specification as the docstring for the module itself. This provides a visible place to put important documentation, but it makes it more difficult to include docstring test cases at the module level.

    此视图函数发送模块 docstring,假设该字符串是有效的 JSON 文档:

            from flask import make_response 
            @dealer.route('/dealer/swagger.json') 
            def swagger(): 
                response = make_response(__doc__.encode('utf-8')) 
                response.headers['Content-Type'] = 'application/json' 
                return response 

    这样做的缺点是需要检查 docstring 的语法以确保它是有效的 JSON。除此之外,还需要验证模块实现是否符合规范。

  3. 使用正确的 Python 语法创建 Python 规范对象。然后可以将其编码为 JSON 并传输。此查看功能发送一个specification对象。这必须是可以序列化为 JSON 符号的有效 Python 对象:

            from flask import make_response 
            import json 
            @dealer.route('/dealer/swagger.json') 
            def swagger3(): 
                response = make_response( 
                    json.dumps(specification, indent=2).encode('utf-8')) 
                response.headers['Content-Type'] = 'application/json' 
                return response 

在所有情况下,拥有正式规范都有几个好处:

  1. 客户机应用程序可以下载规范来微调其处理。
  2. 当包含示例时,规范将成为客户机和服务器的一系列测试用例。
  3. 服务器应用程序还可以使用规范的各种详细信息来提供验证规则、默认值和其他详细信息。

另见

  • 解析请求配方中的查询字符串引入了核心 web 服务
  • 为 web 服务实现身份验证配方将添加身份验证,使服务更加安全

解析 URL 路径

URL 是一个复杂的对象。它至少包含六条独立的信息。更多值可以作为可选值包含。

http://127.0.0.1:5000/dealer/hand/player_1?$format=json这样的 URL 有几个字段:

  • http是方案。https用于使用加密套接字的安全连接。
  • 127.0.0.1可以被称为权威,尽管网络定位更常用。这个特定的 IP 地址意味着本地主机,是一种到本地主机的环回。名称 localhost 映射到此 IP 地址。
  • 5000是端口号,是管理局的一部分。
  • /dealer/hand/player_1是指向资源的路径。
  • $format=json是一个查询字符串。

资源的路径可能相当复杂。在 RESTfulWeb 服务中,通常使用路径信息来标识资源组、单个资源,甚至资源之间的关系。

我们如何处理复杂的路径解析?

准备好了吗

大多数 web 服务提供对某种资源的访问。在使用 WSGI 实现 Web 服务的中,使用 RESTful API 的 Flask 框架,以及解析请求配方中的查询字符串时,资源在 URL 路径上被标识为一只手或多只手。这在某种程度上具有误导性。

这些 web 服务实际上涉及两种资源:

  • 一副牌,可以洗牌以产生一个或多个随机手牌
  • 一只手,被视为对请求的瞬时响应

更令人困惑的是,手动资源是通过GET请求而不是更常见的POST请求创建的。这是令人困惑的,因为GET请求永远不会改变服务器的状态。

对于简单的探索和技术尖峰,GET请求是有帮助的。因为浏览器可以发出GET请求,所以这是探索 web 服务设计某些方面的好方法。

重新设计可以提供对Deck类的随机实例的显式访问。这副牌的一个特点是手牌。这与将Deck作为集合和Hands作为集合中的资源的想法类似:

  • /dealer/decks:一个POST请求将创建一个新的甲板对象。对该请求的响应用于识别唯一组。
  • /dealer/deck/{id}/hands:对此的GET请求将从给定的甲板标识符中获取一个手部对象。查询字符串将指定有多少张卡。查询字符串可以使用$top选项限制返回的手数。它还可以使用$skip选项跳过一些手牌,为以后的手牌获取卡片。

这些查询将需要一个 API 客户端。从浏览器中很难完成这些操作。一种可能是使用 Postman 作为 Chrome 浏览器的插件。我们将利用带有 urllib 配方的生成 REST 请求作为客户端处理这些更复杂 API 的起点。

怎么做。。。

我们将把它分解为两部分:服务器和客户端。

服务器

  1. 首先,将解析请求配方中的查询字符串作为 Flask 应用程序的模板。我们将更改该示例中的视图函数:

            from flask import Flask, jsonify, request, abort, make_response 
            from http import HTTPStatus 
            dealer = Flask('dealer') 
  2. Import any additional modules. In this case, we'll use the uuid module to create a unique key for a shuffled deck:

            import uuid 

    我们还将使用 WerkzeugBadRequest响应。这允许我们提供详细的错误消息。这比使用abort(400)处理错误请求要好一点:

            from werkzeug.exceptions import BadRequest 
  3. 定义全局状态。这包括收集甲板。它还包括随机数生成器。出于测试目的,有一种方法可以强制特定的种子值:

            import os 
            import random 
            random.seed(os.environ.get('DEAL_APP_SEED')) 
            decks = {} 
  4. Define a route—a URL pattern—to a view function that performs a specific request. This is a decorator, placed immediately in front of the function. It will bind the function to the Flask application:

            @dealer.route('/dealer/decks', methods=['POST']) 

    我们已经定义了 decks 资源,并将路由限制为仅处理HTTP POST请求。这缩小了这个特定端点的语义范围,POST请求通常意味着 URL 将在服务器中创建新的内容。在本例中,它在组集合中创建一个新实例。

  5. Define the view function that supports this resource:

            def make_deck(): 
                id = str(uuid.uuid1()) 
                decks[id]= Deck() 
                response_json = jsonify( 
                    status='ok', 
                    id=id 
                ) 
                response = make_response(response_json, HTTPStatus.CREATED) 
                return response 

    uuid1()函数将基于当前主机和随机种子序列生成器创建一个通用唯一 ID。这个字符串版本是一个长的十六进制字符串,看起来像93b8fc06-5395-11e6-9e73-38c9861bf556

    我们将使用此字符串作为键来创建Deck的新实例。响应将是一个带有两个字段的小型 JSON 文档:

    • status字段将为'ok',因为一切都正常。这允许我们提供其他状态信息,包括警告或错误。
    • id字段具有刚创建的组的 ID 字符串。这允许服务器有多个并发游戏,每个游戏都由一个组 ID 来区分。

    响应是通过make_response()函数创建的,因此我们可以提供一个201 CREATED的 HTTP 状态,而不是默认的200 OK。这种区别很重要,因为此请求会更改服务器的状态。

  6. Define a route that requires a parameter. In this case, the route will include the specific deck ID to deal from:

            @dealer.route('/dealer/decks/<id>/hands', methods=['GET']) 

    <id>使它成为一个路径模板,而不是一个简单的文本路径。Flask 将解析/字符并分隔<id>字段。

  7. Define a view function that has parameters which match the template. Since the template included <id> , the view function has a parameter named id as well:

            def get_hands(id): 
                if id not in decks: 
                    dealer.logger.debug(id) 
                    return make_response( 
                        'ID {} not found'.format(id), HTTPStatus.NOT_FOUND) 
                try: 
                    cards = int(request.args.get('cards',13)) 
                    top = int(request.args.get('$top',1)) 
                    skip = int(request.args.get('$skip',0)) 
                    assert skip*cards+top*cards <= len(decks[id].cards), \ 
                        "$skip, $top, and cards larger than the deck" 
                except ValueError as ex: 
                    return BadRequest(repr(ex)) 
                subset = decks[id].cards[skip*cards:(skip+top)*cards] 
                hands = [subset[h*cards:(h+1)*cards] for h in range(top)] 
                response = jsonify( 
                    [ 
                        {'hand':i, 'cards':[card.to_json() for card in hand]} 
                         for i, hand in enumerate(hands) 
                    ] 
                ) 
                return response 

    如果id参数的值不是 decks 集合的键之一,则函数会做出404 NOT FOUND响应。此函数使用BadRequest包含解释性错误消息,而不是使用abort()函数。我们也可以在烧瓶中使用make_response()功能。

    此函数还提取查询字符串中的$top$skipcards的值。在本例中,所有值恰好都是整数,因此每个值都使用了int()函数。对查询参数执行基本的健全性检查。实际上需要进行额外的检查,并鼓励读者仔细考虑所有可能使用的错误参数。

    subset变量是正在处理的甲板部分。我们在skipcards之后开始切割甲板;我们在这一片中只包括了topcards。根据该切片,hands序列将子集分解为top个手,每个手中都有cards。此序列通过jsonify()函数转换为 JSON,并返回。

    默认状态为200 OK,这在这里是合适的,因为此查询是一个幂等元GET请求。每次发送查询时,都会返回相同的卡片集。

  8. 定义运行服务器的主程序:

            if __name__ == "__main__": 
                dealer.run(use_reloader=True, threaded=False) 

客户

这将类似于使用 urllib 配方进行 REST 请求的中的客户端模块:

  1. 导入使用 RESTful API 的基本模块:

            import urllib.request 
            import urllib.parse 
            import json 
  2. 有一系列步骤来发出POST请求,这将创建一个新的洗牌组。首先,通过手动创建一个ParseResult对象,将 URL 分块定义。稍后将折叠为单个字符串:

            full_url = urllib.parse.ParseResult( 
                scheme="http", 
                netloc="127.0.0.1:5000", 
                path="/dealer" + "/decks", 
                params=None, 
                query=None, 
                fragment=None 
            ) 
  3. Build a Request object from the URL, method, and headers:

            request = urllib.request.Request( 
                url = urllib.parse.urlunparse(full_url), 
                method = "POST", 
                headers = { 
                    'Accept': 'application/json', 
                } 
            ) 

    默认方法为GET,不适合此 API 请求。

  4. Send the request and process the response object. For debugging purposes, it can be helpful to print status and header information. Generally, we only need to be sure that the status was the expected 201 .

    响应文档应该是 Python 字典的 JSON 序列化,有两个字段,status 和 ID。此客户端在使用id字段中的值之前确认响应中的状态为ok

            with urllib.request.urlopen(request) as response: 
                # print(response.status) 
                assert response.status == 201 
                # print(response.headers) 
                document = json.loads(response.read().decode("utf-8")) 
    
            print(document) 
            assert document['status'] == 'ok' 
            id = document['id'] 

    在许多 RESTful API 中,都会有一个位置头,它提供一个链接到所创建对象的 URL。

  5. Create a URL that includes inserting the ID into a URL path, as well as providing some query string arguments. This is done by creating a dictionary to model the query string, and then building a URL using a ParseResult object:

            query = {'$top': 4, 'cards': 13} 
    
            full_url = urllib.parse.ParseResult( 
                scheme="http", 
                netloc="127.0.0.1:5000", 
                path="/dealer" + "/decks/{id}/hands".format(id=id), 
                params=None, 
                query=urllib.parse.urlencode(query), 
                fragment=None 
            ) 

    我们已经使用"/decks/{id}/hands/".format(id=id)id值插入到路径中。另一种方法是"/".join(["", "decks", id, "hands", ""])。请注意,空字符串是强制将"/"显示在开头和结尾的一种方式。

  6. 使用完整的 URL、方法和标准头创建Request对象:

            request = urllib.request.Request( 
                url = urllib.parse.urlunparse(full_url), 
                method = "GET", 
                headers = { 
                    'Accept': 'application/json', 
                } 
            ) 
  7. Send the request and process the response. We'll confirm that the response is 200 OK . The response can then be parsed to get the details of the cards that are part of the requested hand:

            with urllib.request.urlopen(request) as response: 
                # print(response.status) 
                assert response.status == 200 
                # print(response.headers) 
                cards = json.loads(response.read().decode("utf-8")) 
    
            print(cards) 

    当我们运行它时,它将创建一个新的Deck实例。然后它将发四手牌,每手 13 张。该查询定义了确切的手数和每只手上的卡数。

它是如何工作的。。。

服务器为集合和集合实例定义了两个遵循公共模式的路由。通常用复数名词decks来定义收集路径。使用复数名词表示 CRUD 操作的重点是在集合中创建实例。

在这种情况下,创建操作通过/dealer/decks路径的POST方法实现。可以通过编写额外的视图函数来处理/dealer/decks路径的GET方法来支持检索。这将公开 decks 集合中的所有 deck 实例。

如果支持删除,可以使用/dealer/decksDELETE方法。更新(使用PUT方法)似乎不适合创建随机组的服务器。

/dealer/decks集合中,通过/dealer/decks/<id>路径识别特定的组。该设计要求使用GET方法从给定的牌组中取出几手牌。

其余的 CRUD 操作创建、更新和删除对于此类Deck对象没有多大意义。一旦创建了Deck对象,客户端应用程序就可以查询甲板上的各种人手。

甲板切片

交易算法将一副牌分成几片。这些切片基于这样一个事实,即一副牌的大小D必须包含足够的牌,以容纳手的数量h,以及每只手的牌的数量c。每手牌和牌的数量不得大于牌组的大小:

h×cD

交易的社交仪式通常包括切牌,这是一个非常简单的洗牌,由非交易玩家完成。传统上,每一张hth卡分配给每一手,hn

Hn={Dn+H×i:0≤ i<c**

上面公式中的想法是手牌Hn=0有卡片H0={D0、DH、D2hc×H、手牌Hn=1有卡片H1={D1、D1+H、D1+2h…、D1+c×H}等。这张牌的分发看起来比简单地给每位玩家下一批c牌更公平。

这并不是必须的,我们的 Python 程序成批处理卡片,使用 Python 计算起来稍微容易一些:

Hn={Dn×c+1:0≤ i<c

Python 代码用卡片H0={DD0、D【T16 1】、D、D【T16 1】、D*、创建了手牌【T00】nnn*c-1},手Hn=1有卡H0{【T42 D】c**0】{1、Dc+2、D*2c-1,等等。给定一个随机牌组,这与任何其他分配的牌一样公平。在 Python 中枚举稍微简单一些,因为它涉及列表切片。有关切片的更多信息,请参阅第 4 章中的切片和切割列表配方内置数据结构–列表、集合、dict*

客户端

此事务的客户端是一系列 RESTful 请求:

  1. 理想情况下,操作从GETswagger.json开始,以获取服务器的规格。根据服务器的不同,这可能简单到:

            with urllib.request.urlopen('http://127.0.0.1:5000/dealer/swagger.json') as         response 
                swagger = json.loads(response.read().decode("utf-8")) 
  2. 然后,有一个POST来创建一个新的Deck实例。这需要创建一个Request对象,以便将该方法设置为POST

  3. 然后,有一个GET从甲板实例中获得一些手。这可以通过将 URL 调整为字符串模板来实现。更一般的做法是将 URL 作为单个字段的集合,而不是简单的字符串。

有两种方法可以处理 RESTful 应用程序的错误:

  • 对找不到的资源使用简单的状态响应,如abort(HTTPStatus.NOT_FOUND)
  • 使用make_response(message, HTTPStatus.BAD_REQUEST)处理在某种程度上无效的请求。该消息可以提供所需的详细信息。

对于其他一些状态代码,例如403 Forbidden,我们可能不想提供太多详细信息。在授权问题的情况下,提供太多细节通常是个坏主意。对于这一点,abort(HTTPStatus.FORBIDDEN)可能是合适的。

还有更多。。。

我们将查看一些我们应该考虑添加到服务器的特性:

  • 检查接受标题中的JSON
  • 提供夸张的规格说明

通常使用头来区分 RESTful API 请求和对服务器的其他请求。Accept 头可以提供一种 MIME 类型,用于区分 JSON 内容请求和面向用户的内容请求。

@dealer.before_request装饰器可用于注入过滤每个请求的函数。此筛选器可以根据以下要求区分适当的 RESTful API 请求:

  • Accept 标头包含一个 MIME 类型,其中包括json。通常,完整的 MIME 字符串是application/json
  • 此外,我们可以对swagger.json文件进行例外处理。这可以被视为 RESTful API 请求,而不考虑任何其他指标。

以下是实现此功能的附加代码:

    @dealer.before_request 
    def check_json(): 
        if request.path == '/dealer/swagger.json': 
            return 
        if 'json' in request.headers.get('Accept', '*/*'): 
            return 
        return abort(HTTPStatus.BAD_REQUEST) 

此筛选器将简单地返回一个无信息的400 BAD REQUEST响应。提供更明确的错误消息可能会泄露太多有关服务器实现的信息。但是,如果它看起来有用,我们可以将abort()替换为make_response(),以返回更详细的错误。

提供大摇大摆的规格说明

行为良好的 RESTful API 为各种可用服务提供了 OpenAPI 规范。通常采用/swagger.json路线包装。这并不一定意味着文本文件可用。取而代之的是,该路径被用作一个焦点,以 JSON 表示法在 Swagger 2.0 规范之后提供详细的接口规范。

我们已经定义了路由/swagger.json,并将函数swagger3()绑定到此路由。此函数将创建全局对象的 JSON 表示,规范:

    @dealer.route('/dealer/swagger.json') 
    def swagger3(): 
        response = make_response(json.dumps(specification, indent=2).encode('utf-8')) 
        response.headers['Content-Type'] = 'application/json' 
        return response 

specification对象具有以下轮廓。重要细节已替换为...以强调整体结构。详情如下:

    specification = { 
        'swagger': '2.0', 
        'info': { 
            'title': '''Python Cookbook\nChapter 12, recipe 5.''', 
            'version': '1.0' 
        }, 
        'schemes': ['http'], 
        'host': '127.0.0.1:5000', 
        'basePath': '/dealer', 
        'consumes': ['application/json'], 
        'produces': ['application/json'], 
        'paths': { 
            '/decks': {...} 
            '/decks/{id}/hands': {...} 
        } 
    } 

这两条路径对应于服务器中的两个@dealer.route装饰器。这就是为什么在开始设计服务器时使用一个招摇过市的规范,然后构建代码以满足规范的原因。

请注意小的语法差异。烧瓶使用/decks/<id>/hands,其中 OpenAPI Swagger 规范使用/decks/{id}/hands。这个小东西意味着我们不能简单地在 Python 和 Swagger 文档之间复制和粘贴。

这是/decks路径。这将显示来自查询字符串的输入参数。它还显示了包含甲板 ID 信息的201响应的详细信息:

    '/decks': { 
     'post': { 
        'parameters': [ 
          { 
            'name': 'size', 
            'in': 'query', 
            'type': 'integer', 
            'default': 1, 
                'description': '''number of decks to build and shuffle''' 
          } 
        ], 
        'responses': { 
          '201': { 
            'description': '''Create and shuffle a deck. Returns a unique deck id.''', 
            'schema': { 
              'type': 'object', 
                'properties': { 
                  'status': {'type': 'string'}, 
                  'id': {'type': 'string'} 
                } 
              } 
            }, 
          '400': { 
            'description': '''Request doesn't accept a JSON response''' 
          } 
        } 
      } 

/decks/{id}/hands路径具有类似的结构。它定义查询字符串中可用的所有参数。它还定义了各种响应;包含卡的200响应,当找不到 ID 值时定义404响应。

我们省略了每个路径参数的一些细节。我们还省略了甲板结构的细节。但是,大纲总结了 RESTful API:

  • swagger键必须设置为2.0
  • info键可以提供大量信息。此示例仅具有最低要求。
  • schemeshostbasePath字段定义了用于此服务的 URL 的一些公共元素。
  • consumes字段说明请求Content-Type应包括的内容。
  • produces字段同时表示这两种情况;请求接受头必须声明,以及响应Content-Type将是什么。
  • paths字段标识此服务器上提供响应的所有路径。这显示了/decks/decks/{id}/hands路径。

swagger3()函数将此 Python 对象转换为 JSON 表示法并返回它。这实现了一个swagger.json文件的下载。内容指定 RESTful API 服务器提供的资源。

使用夸张的规格说明

在客户端编程中,我们使用了简单的文本值来构建 URL。该示例如下所示:

    full_url = urllib.parse.ParseResult( 
        scheme="http", 
        netloc="127.0.0.1:5000", 
        path="/dealer" + "/decks", 
        params=None, 
        query=None, 
        fragment=None 
    ) 

这一部分可能来自于招摇过市的规范。例如,我们可以使用specification['host']specification['basePath']代替netloc值和path值的第一部分。这种对招摇过市规范的使用可以提供一点额外的灵活性。

招摇过市的规范是供人们在设计决策时使用的工具使用的。it 的真正目的是推动 API 的自动化测试。通常,Swagger 规范将包含详细的示例,有助于阐明如何编写客户端应用程序。

另见

  • 有关 RESTful web 服务的更多示例,请参见使用 urllib发出 REST 请求和解析请求配方中的查询字符串

解析 JSON 请求

许多 web 服务都涉及创建新的持久对象或更新现有持久对象的请求。为了执行这些类型的操作,应用程序将需要来自客户端的输入。

RESTful web 服务通常接受 JSON 文档形式的输入(并生成输出)。有关 JSON 的更多信息,请参见第 9 章中的阅读 JSON 文档配方、输入/输出、物理格式和逻辑布局

我们如何解析来自 web 客户端的 JSON 输入?验证输入的简单方法是什么?

准备好了吗

我们将从解析请求配方中的查询字符串扩展 Flask 应用程序,以添加用户注册功能;这将添加一个玩家,然后他可以申请卡。玩家是一种涉及基本 CRUD 操作的资源:

  • 客户端可以对/players路径执行POST操作以创建新的播放器。这将包括描述播放器的文档的有效负载。该服务将验证该文档,如果该文档有效,则创建一个新的持久性Player实例。响应将包括分配给玩家的 ID。如果文档无效,将返回一个详细说明问题的响应。
  • 客户端可以对/players路径执行GET操作,以获取玩家列表。
  • 客户可以对/players/<id>路径执行GET操作,以获取特定玩家的详细信息。
  • 客户端可以对/players/<id>路径执行PUT操作,以更新特定玩家的详细信息。与初始的POST一样,这需要一个必须验证的有效负载文档。
  • 客户端可以对/players/<id>路径执行DELETE操作以移除播放器。

解析请求配方中的查询字符串一样,我们将实现这些服务的客户端和服务器部分。服务器将处理基本的POSTGET操作。我们将把PUTDELETE操作留给读者作为练习。

我们需要一个 JSON 验证器。参见https://pypi.python.org/pypi/jsonschema/2.5.1 。这特别好。拥有一个招摇过市的规范验证器也是很有帮助的。参见https://pypi.python.org/pypi/swagger-spec-validator

如果我们安装了swagger-spec-validator包,这也会安装jsonschema项目的最新副本。下面是整个序列的外观:

MacBookPro-SLott:pyweb slott$ pip3.5 install swagger-spec-validator

Collecting swagger-spec-validator

Downloading swagger_spec_validator-2.0.2.tar.gz

Requirement already satisfied (use --upgrade to upgrade):

jsonschema in /Library/.../python3.5/site-packages

(from swagger-spec-validator)

Requirement already satisfied (use --upgrade to upgrade):

setuptools in /Library/.../python3.5/site-packages

(from swagger-spec-validator)

Requirement already satisfied (use --upgrade to upgrade):

six in /Library/.../python3.5/site-packages

(from swagger-spec-validator)

Installing collected packages: swagger-spec-validator

Running setup.py install for swagger-spec-validator ... done

Successfully installed swagger-spec-validator-2.0.2

我们使用了pip命令来安装swagger-spec-validator包。本次安装还检查了jsonschemasetuptoolssix是否已安装。

有一个关于使用--upgrade的提示。使用这样的命令可以帮助升级包:pip install jsonschema --upgrade。如果有低于 2.5.0 版本的jsonschema版本,这可能是必要的。

怎么做。。。

我们将把它分解为三个部分:Swagger 规范、服务器和客户端。

昂首阔步规范

  1. Here's the outline of the Swagger specification:

            specification = { 
                'swagger': '2.0', 
                'info': { 
                    'title': '''Python Cookbook\nChapter 12, recipe 6.''', 
                    'version': '1.0' 
                }, 
                'schemes': ['http'], 
                'host': '127.0.0.1:5000', 
                'basePath': '/dealer', 
                'consumes': ['application/json'], 
                'produces': ['application/json'], 
                'paths': { 
                    '/players': {...}, 
                    '/players/{id}': {...}, 
                } 
                'definitions': { 
                    'player: {..} 
                } 
            } 

    第一个字段是 RESTfulWeb 服务的基本样板。pathsdefinitions将填入 URL 和模式定义,它们是服务的一部分。

  2. Here's the schema definition used to validate a new player. This goes inside the definition of the overall specification:

            'player': { 
                'type': 'object', 
                'properties': { 
                    'name': {'type': 'string'}, 
                    'email': {'type': 'string', 'format': 'email'}, 
                    'year': {'type': 'integer'}, 
                    'twitter': {'type': 'string', 'format': 'uri'} 
                } 
            } 

    整个输入文档被正式描述为具有对象类型。该对象有四个属性:

    • 一个名称,它是一个字符串
    • 电子邮件地址,是具有特定格式的字符串
    • Twitter URL,它是具有给定格式的字符串
    • 一年,这是一个数字

    JSON 模式规范语言中有一些已定义的格式。emailurl格式被广泛使用。完整的格式列表包括date-timehostnameipv4ipv6uri。有关定义架构的详细信息,请参见http://json-schema.org/documentation.html

  3. Here's the overall players path that's used to create a new player or get the entire collection of players:

            '/players': { 
                'post': { 
                    'parameters': [ 
                            { 
                                'name': 'player', 
                                'in': 'body', 
                                'schema': {'$ref': '#/definitions/player'} 
                            }, 
                        ], 
                    'responses': { 
                        '201': {'description': 'Player created', }, 
                        '403': {'description': 'Player is invalid or a duplicate'} 
                    } 
                }, 
                'get': { 
                    'responses': { 
                        '200': {'description': 'All of the players defined so far'}, 
                    } 
                } 
            }, 

    此路径定义了两种方法-postgetpost方法有一个参数,称为player。此参数是请求的主体,它遵循定义部分中提供的播放器模式。

    显示的get方法没有任何参数或响应结构的任何形式定义。

  4. Here's the definition of a path to get details about a specific player:

            '/players/{id}': { 
                'get': { 
                    'parameters': [ 
                        { 
                            'name': 'id', 
                            'in': 'path', 
                            'type': 'string' 
                        } 
                    ], 
                    'responses': { 
                        '200': { 
                            'description': 'The details of a specific player', 
                            'schema': {'$ref': '#/definitions/player'} 
                        }, 
                        '404': {'description': 'Player ID not found'} 
                    } 
                } 
            }, 

    该路径类似于解析 URL 路径配方中所示的路径。URL 中提供了player键。玩家 ID 有效时的响应将详细显示。响应有一个已定义的模式,该模式还使用定义部分中的播放器模式定义。

    此规范将是服务器的一部分。可通过@dealer.route('/swagger.json')路由中定义的查看功能提供。创建包含此规范文档的文件通常是最简单的。

服务器

  1. 首先,将解析请求配方中的查询字符串作为 Flask 应用程序的模板。我们将更改视图功能:

            from flask import Flask, jsonify, request, abort, make_response 
            from http import HTTPStatus 
  2. 导入所需的其他库。我们将使用 JSON 模式进行验证。我们还将计算字符串的散列作为 URL 中有用的外部标识符:

            from jsonschema import validate 
            from jsonschema.exceptions import ValidationError 
            import hashlib 
  3. 创建应用程序和玩家数据库。我们将使用一个简单的全局变量。较大的应用程序可能会使用适当的数据库服务器来保存此信息:

            dealer = Flask('dealer') 
            players = {} 
  4. 定义发布到players

            @dealer.route('/dealer/players', methods=['POST']) 

    整体集合的路线

  5. Define the function that will parse the input document, validate the content, and then create the persistent player object:

            def make_player(): 
                document = request.json 
                player_schema = specification['definitions']['player'] 
                try: 
                    validate(document, player_schema) 
                except ValidationError as ex: 
                    return make_response(ex.message, 403) 
    
                id = hashlib.md5(document['twitter'].encode('utf-8')).hexdigest() 
                if id in players: 
                    return make_response('Duplicate player', 403) 
    
                players[id] = document 
    
                response = make_response( 
                    jsonify( 
                        status='ok', 
                        id=id 
                    ), 
                    201 
                ) 
                return response 

    此功能遵循常见的四步设计:

    • 验证输入文档。该模式被定义为总体招摇过市规范的一部分。
    • 创建一个密钥并确认它是唯一的。这是从数据派生的密钥。我们还可以使用uuid模块创建唯一的密钥。
    • 将新文档持久保存在数据库中。在本例中,它只是一条语句,players[id] = document。这遵循了一个理想,即 RESTful API 是围绕已经提供了完整功能实现的类和函数构建的。
    • 构建一个响应文档。
  6. 定义运行服务器的主程序:

            if __name__ == "__main__": 
                dealer.run(use_reloader=True, threaded=False) 

我们可以添加其他方法来查看多个玩家或单个玩家。这些将遵循解析 URL 路径配方的基本设计。我们将在下一节中介绍这些。

客户

这将类似于解析 URL 路径配方中的客户端模块:

  1. 导入使用 RESTful API 的基本模块:

            import urllib.request 
            import urllib.parse 
            import json 
  2. 通过手动创建一个ParseResult对象,创建一个完整的 URL。稍后将折叠为单个字符串:

            full_url = urllib.parse.ParseResult( 
                scheme="http", 
                netloc="127.0.0.1:5000", 
                path="/dealer" + "/players", 
                params=None, 
                query=None, 
                fragment=None 
            ) 
  3. 创建一个可以序列化为 JSON 文档并发布到服务器的对象。研究swagger.json说明了该文档的模式必须是什么。document将包括所需的四个属性:

            document = { 
                'name': 'Xander Bowers', 
                'email': '[email protected]', 
                'year': 1985, 
                'twitter': 'https://twitter.com/PacktPub' 
            } 
  4. We'll combine URL, document, method, and headers to create the complete request. This will use urlunparse() to collapse the URL parts into a single string. The Content-Type header alerts the server that we're going to provide a text document in JSON notation:

            request = urllib.request.Request( 
                url = urllib.parse.urlunparse(full_url), 
                method = "POST", 
                headers = { 
                    'Accept': 'application/json', 
                    'Content-Type': 'application/json;charset=utf-8', 
                }, 
                data = json.dumps(document).encode('utf-8') 
            ) 

    我们已经包括了charset选项,它指定了用于从 Unicode 字符串创建字节的特定编码。因为utf-8编码是默认编码,所以不需要。在使用不同编码的罕见情况下,这说明了如何提供替代方案。

  5. Send the request and process the response object. For debugging purposes, it can be helpful to print the status and headers information. Generally, we only need to be sure that the status was the expected 201 CREATED :

            with urllib.request.urlopen(request) as response: 
                # print(response.status) 
                assert response.status == 201 
                # print(response.headers) 
                document = json.loads(response.read().decode("utf-8")) 
    
            print(document) 
            assert document['status'] == 'ok' 
            id = document['id'] 

    我们已经检查了响应文档,以确保它包含两个预期字段。

我们还可以在此客户端中包含其他查询。我们可能希望检索所有玩家或检索特定玩家。这些将遵循解析 URL 路径配方中所示的设计。

它是如何工作的。。。

Flask 自动检查入站文档以解析它们。我们可以简单地使用request.json来利用 Flask 内置的自动 JSON 解析。

如果输入实际上不是 JSON,那么 Flask 框架将返回一个400 BAD REQUEST响应。当我们的服务器应用程序引用请求的json属性时,就会发生这种情况。我们可以使用try语句捕获400 BAD REQUEST响应对象并对其进行更改,或者可能返回不同的响应。

我们已经使用jsonschema包来验证输入文档。这将检查 JSON 文档的一些功能:

  • 它检查 JSON 文档的总体类型是否与模式的总体类型匹配。在本例中,模式需要一个对象,它是一个{}JSON 结构。
  • 对于架构中定义并显示在文档中的每个属性,它确认文档中的值与架构定义匹配。这意味着该值适合定义的 JSON 类型之一。如果存在其他验证规则,如格式、范围规范或数组的多个元素,则也会检查这些约束。该检查在模式的所有级别递归进行。
  • 如果有必需的字段列表,它会检查文档中是否存在所有这些字段。

对于这个配方,我们将模式的细节保持在最低限度。我们在本例中省略的一个常见特性是所需属性的列表。我们还可以提供更详细的属性描述。例如,年份的最小值可能为1900

在本例中,我们将数据库更新处理保持在最低限度。在某些情况下,数据库插入可能涉及更复杂的过程,其中数据库客户端连接用于执行更改数据库服务器状态的命令。理想情况下,数据库处理保持在最低限度,特定于应用程序的详细信息通常从单独的模块导入,并以 RESTful API 资源的形式呈现。

在一个更大的应用程序中,可能会有一个player_db模块,其中包括所有播放器数据库处理。这个模块将定义所有的类和函数。这通常会为player对象提供详细的模式定义。RESTfulAPI 服务将导入这些类、函数和模式规范,并向外部使用者公开它们。

还有更多。。。

Swagger 规范允许响应文档的示例。这通常有几个方面的帮助:

  • 开始设计作为响应一部分的示例文档是很常见的。编写描述文档的模式规范可能很困难,模式验证功能有助于确保规范与文档匹配。
  • 规范完成后,下一步是编写服务器端编程。使用利用模式示例文档的单元测试是很有帮助的。
  • 对于 Swagger 规范的用户,可以使用响应的具体示例来设计客户端,并为客户端编程编写单元测试。

我们可以使用以下代码来确认服务器是否具有有效的招摇过市规范。如果出现异常,则可能是因为没有 Swagger 文档,或者文档不适合 Swagger 架构:

    from swagger_spec_validator import validate_spec_url
    validate_spec_url('http://127.0.0.1:5000/dealer/swagger.json') 

定位头

201 CREATED响应包括一个带有一些状态信息的小文档。状态信息包括分配给新创建的记录的键。

201 CREATED响应在响应中有额外的位置头也是很常见的。此标头将提供可用于恢复已创建文档的 URL。对于此应用程序,位置将是 URL,如以下示例:http://127.0.0.1:5000/dealer/players/75f1bfbda3a8492b74a33ee28326649c

客户端可以保存位置标头。完整的 URL 比从 URL 模板和值创建 URL 稍微简单一些。

服务器可以按如下方式构建此标头:

    response.headers['Location'] = url_for('get_player', id=str(id)) 

这取决于烧瓶url_for()功能。此函数采用视图函数的名称以及来自 URL 路径的任何参数。然后,它使用 view 函数的路由来构造一个完整的 URL。这将包括当前运行的服务器的所有信息。插入表头后,可以返回response对象。

额外资源

服务器应该能够响应一个玩家列表。下面是一个简单的实现,它将数据转换为一个大型 JSON 文档:

    @dealer.route('/dealer/players', methods=['GET']) 
    def get_players(): 
        response = make_response(jsonify(players)) 
        return response 

一个更复杂的实现将支持$top$skip查询参数来分页浏览玩家列表。此外,$filter选项可能有助于实现对玩家子集的搜索。

除了对所有玩家的通用查询之外,我们还需要实现一个返回单个玩家的方法。这种视图函数通常同样简单,如下代码所示:

    @dealer.route('/dealer/players/<id>', methods=['GET']) 
    def get_player(id): 
        if id not in players: 
            return make_response("{} not found".format(id), 404) 

        response = make_response( 
            jsonify( 
                players[id] 
            ) 
        ) 
        return response 

此函数确认给定 ID 是数据库中正确的键值。如果密钥不在数据库中,数据库文档将转换为 JSON 符号并返回。

查询特定玩家

以下是在数据库中定位特定值所需的客户端处理。这涉及多个步骤:

  1. First, we'll create the URL for a particular player:

            id = '75f1bfbda3a8492b74a33ee28326649c' 
            full_url = urllib.parse.ParseResult( 
                scheme="http", 
                netloc="127.0.0.1:5000", 
                path="/dealer" + "/players/{id}".format(id=id), 
                params=None, 
                query=None, 
                fragment=None 
            ) 

    我们已经根据一些信息构建了 URL。它被创建为一个带有单独字段的ParseResult对象。

  2. 给定 URL,我们可以创建一个Request对象:

            request = urllib.request.Request( 
                url = urllib.parse.urlunparse(full_url), 
                method = "GET", 
                headers = { 
                    'Accept': 'application/json', 
                } 
            ) 
  3. Once we have the request object, we can then make the request, and retrieve the response. We need to confirm that the response status is 200 . If so, we can then parse the body of the response to get the JSON document that describes a given player:

            with urllib.request.urlopen(request) as response: 
                assert response.status == 200 
                player= json.loads(response.read().decode("utf-8")) 
            print(player) 

    如果播放器不存在,urlopen()函数将引发异常。我们可以将其包含在一个try语句中,以捕获在玩家 ID 不存在时可能引发的403 NOT FOUND异常。

异常处理

以下是所有客户端请求的一般模式。这包括明确的try声明:

    try: 
        with urllib.request.urlopen(request) as response: 
            # print(response.status) 
            assert response.status == 201 
            # print(response.headers) 
            document = json.loads(response.read().decode("utf-8")) 

        # process the document here. 

    except urllib.error.HTTPError as ex: 
        print(ex.status) 
        print(ex.headers) 
        print(ex.read()) 

实际上有两种常见的例外情况:

  • 下级异常:此异常表示无法联系服务器。ConnectionError异常是这种较低级别异常的常见示例。这是OSError异常的一个子类。
  • 来自 urllib 模块的 HTTPError 异常:此异常表示整个 HTTP 协议工作正常,但来自服务器的响应不是成功的状态码。成功通常是一个介于200299之间的值。
  • HTTPError异常具有与正确响应类似的属性。它包括状态、标题和正文。

在某些情况下,HTTPError异常可能是来自服务器的多个预期响应之一。它可能不表示错误或问题。它可能只是另一个有意义的状态代码。

另见

  • 有关 URL 处理的其他示例,请参见解析 URL 路径配方。
  • 使用 urllib 配方进行 REST 请求的展示了查询字符串处理的其他示例。

实现 web 服务的身份验证

总的来说,安全是一个普遍存在的问题。应用程序的每个部分都有安全考虑。部分安全措施的实施将涉及两个密切相关的问题:

  • 认证:客户必须提供一些他们是谁的证据。这可能涉及签名证书,也可能涉及用户名和密码等凭据。它可能涉及多个因素,例如用户应该可以访问的手机短信。web 服务器必须验证此身份验证。
  • 授权:服务器必须定义权限区域并将其分配给用户组。此外,必须将单个用户定义为授权组的成员。

虽然从技术上讲,可以单独定义授权,但随着站点或应用程序的增长和更改,这往往会变得很尴尬。为组定义安全性更容易。在某些情况下,一个群体(最初)可能只有一个个体。

应用软件必须实现授权决策。对于 Flask,授权可以是每个视图功能的一部分。“个人到组”和“组到视图”功能的连接定义了可供任何特定用户使用的资源。

令人困惑的是,HTTP 标准使用 HTTPAuthorization头提供身份验证凭据。这可能会导致一些混淆,因为标题的名称不能准确反映其用途。

可以通过多种方式从 web 客户端向 web 服务器提供身份验证详细信息。以下是一些备选方案:

  • 证书:经过加密的证书,包括数字签名以及对证书****权限CA)的引用:这些证书通过安全套接字层SSL进行交换。在某些环境中,客户端和服务器都必须具有用于相互身份验证的证书。在其他环境中,服务器提供真实性证书,但客户端不提供。这在https方案中很常见。服务器未验证客户端的证书。
  • 静态 API 密钥或令牌:web 服务可能提供简单的固定密钥。这可能会被告知要保密,就像密码一样。
  • 用户名和密码:web 服务器可能通过用户名和密码识别用户。可以使用电子邮件或 SMS 消息进一步确认用户身份。
  • 第三方身份验证:这可能涉及使用 OpenID 等服务。详见http://openid.net 。这将涉及回调 URL,以便 OpenID 提供程序可以返回通知信息。

此外,还有一个问题是如何将用户信息加载到 web 服务器中。有些网站是自助式的,用户提供一些最低限度的联系信息,并被授予访问内容的权限。

在许多情况下,网站不是自助服务。用户在被允许访问之前可能会经过仔细的审查。访问可能涉及访问数据或服务的合同和费用。在某些情况下,一家公司将为其员工购买许可证,提供一个有限的用户列表,这些用户可以访问给定的一套 web 服务。

此配方将显示一个自助服务应用程序,其中没有定义的用户集。这意味着必须有一个 web 服务来创建不需要任何身份验证的新用户。所有其他服务都需要经过适当身份验证的用户。

准备好了吗

我们将使用Authorization头实现一个基于 HTTP 的身份验证版本。此主题有两种变体:

  • HTTP 基本身份验证:使用简单的用户名和密码字符串。它依赖 SSL 层来加密客户端和服务器之间的通信。
  • HTTP 摘要身份验证:这使用了更复杂的用户名、密码和服务器提供的 nonce 散列。服务器计算期望的哈希值。如果散列值匹配,则使用相同的字节计算散列,并且密码必须有效。这不需要 SSL。

web 服务器经常使用 SSL 来确定其真实性。因为这种技术非常普及,这意味着可以使用 HTTP 基本身份验证。这是 RESTful API 处理中的一个巨大简化,因为每个请求都将包含Authorization头,客户端和服务器之间将使用安全套接字。

配置 SSL

获取和配置证书的细节超出了 Python 编程的范围。OpenSSL 包提供了用于创建可用于配置安全服务器的自签名证书的工具。CAS,如科摩多集团和赛门铁克提供可信证书,这是广泛认可的 OS 供应商,以及 Mozilla 基金会。

使用 OpenSSL 创建证书有两个部分:

  1. Create a private key file. This is generally done with the following OS-level command:

     slott$ openssl genrsa 1024 > ssl.key
    
     Generating RSA private key, 1024 bit long modulus
    
     .......++++++
    
     ..........................++++++
    
     e is 65537 (0x10001)

    openssl genrsa 1024命令创建了一个私钥文件,该文件以ssl.key的名称保存。

  2. Create a certificate using the key file. The following command is one way to handle this:

     slott$ openssl req -new -x509 -nodes -sha1 -days 365 -key ssl.key > ssl.cert 

    您将被要求输入将纳入证书申请的信息。您将要输入的是所谓的可分辨名称DN)。有相当多的字段,但您可以保留一些空白。对于某些字段,将有一个默认值。如果您输入.,该字段将留空。

     Country Name (2 letter code) [AU]:US
    
     State or Province Name (full name) [Some-State]:Virginia 
    
     Locality Name (eg, city) []: 
    
     Organization Name (eg, company) [Internet Widgits Pty Ltd]:ItMayBeAHack 
    
     Organizational Unit Name (eg, section) []:
    
    Common Name (e.g. server FQDN or YOUR name) []:Steven F. Lott 
    
     Email Address []:

    命令openssl req -new -x509 -nodes -sha1 -days 365 -key ssl.key创建了私有证书文件,保存在ssl.cert中。此证书是私人签名的,没有 CA。它只提供有限的一组功能。

这两个步骤创建两个文件:ssl.certssl.key。我们将使用下面的这些文件来保护服务器。

用户和凭证

为了让用户能够提供用户名和密码,我们需要将这些信息存储在服务器上。关于用户凭据,有一条非常重要的规则:

提示

永远不要存储凭据。从不

应该清楚的是,存储纯文本密码会引发安全灾难。不太明显的是,我们甚至不能存储加密的密码。当用于加密密码的密钥被泄露时,将导致所有用户身份的丢失。

如果我们不存储密码,如何检查用户的密码?

解决方案是存储哈希而不是密码。第一次创建密码时,服务器保存哈希摘要。每次之后,用户的输入都会被散列并与保存的散列进行比较。如果两个哈希匹配,则密码必须正确。最重要的是从散列中恢复密码非常困难。

创建密码初始哈希值的过程分为三步:

  1. 创建一个随机的salt值。通常使用os.urandom()中的 16 个字节。
  2. 使用salt加上密码创建hash值。通常情况下,hashlib用于此目的。具体来说,hashlib.pbkdf2_hmac()。为此使用特定的摘要算法,例如,md5 sha224
  3. 保存摘要名称、salt和散列字节。通常,这会组合成一个类似于-md5$salt$hash的字符串。md5是一个文字。$分隔算法名称、salthash值。

当需要检查密码时,将遵循类似的过程:

  1. 给定用户名,找到保存的哈希字符串。这将有一个由三部分组成的结构:摘要算法名称、保存的 salt 和散列字节。这些元素可以用$分隔。
  2. 使用保存的 salt 加上用户提供的候选密码创建计算出的hash值。
  3. 如果计算出的哈希字节与保存的哈希字节匹配,则知道摘要算法和 salt 匹配;因此,密码也必须匹配。

我们将定义一个简单的类来保留用户信息和散列密码。我们可以使用 Flask 的g对象在请求处理期间保存用户信息。

烧瓶视图功能装饰器

处理身份验证检查有几个备选方案:

  • 如果每个路由都有相同的安全要求,则可以使用@dealer.before_request功能验证所有Authorization头。这需要对/swagger.json路由和自助服务路由进行一些异常处理,允许未经授权的用户创建新的用户名和密码凭据。
  • 当一些路由需要身份验证,而另一些路由不需要身份验证时,为需要身份验证的路由引入一个装饰器效果很好。

Python 装饰器是一个包装另一个函数以扩展其功能的函数。核心技术如下所示:

    from functools import wraps 
    def decorate(function): 
        @wraps(function) 
        def decorated_function(*args, **kw): 
            # processing before 
            result = function(*args, **kw) 
            # processing after 
            return result 
        return decorated_function 

我们的想法是用一个新函数decorated_function替换给定函数function。在修饰函数的主体内,它执行原始函数。一些处理可以在函数装饰之前完成,一些处理可以在函数装饰之后完成。

在烧瓶上下文中,我们将把我们的装饰者放在@route装饰者之后:

    @dealer.route('/path/to/resource') 
    @decorate 
    def view_function(): 
        return make_result('hello world', 200) 

我们已经用@decorate装饰师包装了view_function()。装饰者可以检查身份验证以确保用户是已知的。我们可以在这些函数中进行各种各样的处理。

怎么做。。。

我们将此分解为四个部分:

  • 定义User
  • 定义视图装饰器
  • 创建服务器
  • 创建示例客户端

定义用户类

该类定义提供了单个User对象的定义示例:

  1. Import modules that are required to create and check the password:

            import hashlib 
            import os 
            import base64 

    其他有用的模块包括json,以便User对象可以正确序列化。

  2. 定义User类:

            class User: 
  3. Since we'll be changing some aspects of password generation and checking, we'll provide two constants as part of the overall class definition:

            DIGEST = 'sha384' 
            ROUNDS = 100000 

    我们将使用SHA-384摘要算法。这提供了 64 字节的摘要。我们将使用 100000 轮用于基于密码的密钥派生函数 2(PBKDF2算法。

  4. Most of the time, we'll create users from a JSON document. This will be a dictionary that can be turned into keyword argument values using ** :

            def __init__(self, **document): 
                self.name = document['name'] 
                self.year = document['year'] 
                self.email = document['email'] 
                self.twitter = document['twitter'] 
                self.password = None 

    请注意,我们不希望直接设置密码。相反,我们将在创建用户文档时单独设置密码。

    我们省略了其他授权细节,例如用户所属的组列表。我们还省略了一个指示需要更改密码的指示器。

  5. Define the algorithm for setting the password hash value:

            def set_password(self, password): 
                salt = os.urandom(30) 
                hash = hashlib.pbkdf2_hmac( 
                    self.DIGEST, password.encode('utf-8'), salt, self.ROUNDS) 
                self.password = '$'.join( 
                    [self.DIGEST, 
                     base64.urlsafe_b64encode(salt).decode('ascii'), 
                     base64.urlsafe_b64encode(hash).decode('ascii') 
                    ] 
                ) 

    我们使用os.urandom()构建了一个随机盐。然后,我们使用给定的摘要算法、密码和salt构建了完整的hash值。我们使用了可配置的轮数。

    请注意,哈希计算以字节为单位,而不是 Unicode 字符。我们使用utf-8编码将密码编码为字节。

    我们使用摘要算法的名称、salt 和编码的hash值组合了一个字符串。我们对字节使用了 URL 安全的base64编码,因此可以轻松显示完整的散列密码值。它可以保存在任何类型的数据库中,因为它只使用了A-Za-z0-9-_

    注意,urlsafe_b64encode()创建了一个字节值字符串。必须对这些字符进行解码,以查看它们所代表的 Unicode 字符。我们在这里使用 ASCII 编码方案,因为base64只使用 64 个标准 ASCII 字符。

  6. Define an algorithm for checking a password hash value:

            def check_password(self, password): 
                digest, b64_salt, b64_expected_hash = self.password.split('$') 
                salt = base64.urlsafe_b64decode(b64_salt) 
                expected_hash = base64.urlsafe_b64decode(b64_expected_hash) 
                computed_hash = hashlib.pbkdf2_hmac( 
                    digest, password.encode('utf-8'), salt, self.ROUNDS) 
                return computed_hash == expected_hash 

    我们已经将密码散列分解为digestsaltexpected_hash值。由于各个部分是base64编码的,因此必须对它们进行解码以恢复原始字节。

    请注意,哈希计算以字节为单位,而不是 Unicode 字符。我们使用utf-8编码将密码编码为字节。将hashlib.pbkdf2_hmac()的计算结果与预期结果进行比较。如果它们匹配,则密码必须相同。

下面演示如何使用该类:

>>> details = {'name': 'xander', 'email': '[email protected]', 
...     'year': 1985, 'twitter': 'https://twitter.com/PacktPub' } 
>>> u = User(**details) 
>>> u.set_password('OpenSesame') 
>>> u.check_password('opensesame') 
False 
>>> u.check_password('OpenSesame') 
True

这个测试用例可以包含在类 docstring 中。有关此类测试用例的更多信息,请参见第 11 章测试中的使用 docstring 进行测试配方。

在更复杂的应用程序中,还可能有用户集合的定义。这通常使用某种数据库来帮助定位用户和插入新用户。

定义视图装饰器

  1. functools导入@wraps装饰器。通过确保新函数具有从被修饰函数复制的原始名称和 docstring:

            from functools import wraps 

    ,这有助于定义修饰器

  2. 为了检查密码,我们需要base64模块帮助分解Authorization头的值。我们还需要报告错误,并使用全局g对象

            import base64 
            from flask import g 
            from http import HTTPStatus 

    更新烧瓶处理上下文

  3. 定义装饰器。所有的装饰师都有这个基本的轮廓。我们将在下一步中更换processing here部件:

            def authorization_required(view_function): 
                @wraps(view_function) 
                def decorated_function(*args, **kwargs): 
                    processing here 
                return decorated_function 
  4. 以下是检查标头的处理步骤。请注意,遇到的每一个问题都会以401 UNAUTHORIZED作为状态代码中止处理。为了防止黑客探索该算法,所有结果都是相同的,尽管根本原因不同:

            if 'Authorization' not in request.headers: 
                abort(HTTPStatus.UNAUTHORIZED) 
            kind, data = request.headers['Authorization'].split() 
            if kind.upper() != 'BASIC': 
                abort(HTTPStatus.UNAUTHORIZED) 
            credentials = base64.decode(data) 
            username, _, password = credentials.partition(':') 
            if username not in user_database: 
                abort(HTTPStatus.UNAUTHORIZED) 
            if not user_database[username].check_password(password): 
                abort(HTTPStatus.UNAUTHORIZED) 
            g.user = user_database[username] 
            return view_function(*args, **kwargs) 

必须成功通过许多条件:

  • 必须存在Authorization标题
  • 标头必须指定基本身份验证
  • 该值必须包含使用base64编码的username:password字符串
  • 用户名必须是已知的用户名
  • 从密码计算的哈希必须与预期的密码哈希匹配

任何单一故障都会导致401 UNAUTHORIZED响应。

创建服务器

这与解析 JSON 请求配方中所示的服务器类似。有一些重要的修改:

  1. 创建本地自签名证书或从证书颁发机构购买证书。对于这个配方,我们假设两个文件名是ssl.certssl.key

  2. 导入构建服务器所需的模块。同时导入User类定义:

            from flask import Flask, jsonify, request, abort, url_for 
            from ch12_r07_user import User 
            from http import HTTPStatus 
  3. 包括@authorization_required装饰器定义。

  4. Define a route with no authentication. This will be used to create new users. A similar view function was defined in the Parsing a JSON request recipe. This version requires a password property in the incoming document. This will be the plain-text password that's used to create the hash. The plain text password is not saved anywhere; only the hash is retained:

            @dealer.route('/dealer/players', methods=['POST']) 
            def make_player(): 
                try: 
                    document = request.json 
                except Exception as ex: 
                    # Document wasn't even JSON. We can fine-tune 
                    # the error message here. 
                    raise 
                player_schema = specification['definitions']['player'] 
                try: 
                    validate(document, player_schema) 
                except ValidationError as ex: 
                    return make_response(ex.message, 403) 
    
                id = hashlib.md5(document['twitter'].encode('utf-8')).hexdigest() 
                if id in user_database: 
                    return make_response('Duplicate player', 403) 
    
                new_user = User(**document) 
                new_user.set_password(document['password']) 
                user_database[id] = new_user 
    
                response = make_response( 
                    jsonify( 
                        status='ok', 
                        id=id 
                    ), 
                    201 
                ) 
                response.headers['Location'] = url_for('get_player', id=str(id)) 
                return response 

    创建用户后,将单独设置密码。这遵循一些应用程序设置的模式,其中用户是批量加载的。此处理可能为每个用户提供临时密码,必须立即更改。

    请注意,每个用户都被分配了一个神秘的 ID。分配的 ID 是根据其 Twitter 句柄的十六进制摘要计算出来的。这是不寻常的,但它表明有很大的灵活性。

    如果我们希望用户选择自己的用户名,我们需要将其添加到请求文档中。我们将使用该用户名而不是计算的 ID 值。

  5. Define a route for which authentication is required. A similar view function was defined in the Parsing a JSON request recipe. This version uses the @authorization_required decorator:

            @dealer.route('/dealer/players/<id>', methods=['GET']) 
            @authorization_required 
            def get_player(id): 
                if id not in user_database: 
                    return make_response("{} not found".format(id), 404) 
    
                response = make_response( 
                    jsonify( 
                        players[id] 
                    ) 
                ) 
                return response 

    大多数其他路线都会有类似的@authorization_required装饰器。某些路线,如/swagger.json路线,将不需要授权。

  6. ssl模块定义ssl.SSLContext类。可以使用先前创建的自签名证书和私钥文件加载上下文。然后,Flask 对象的run()方法使用上下文。这会将 URL 中的方案从http://127.0.01:5000更改为https://127.0.0.1:5000

            import ssl 
            ctx = ssl.SSLContext(ssl.PROTOCOL_SSLv23) 
            ctx.load_cert_chain('ssl.cert', 'ssl.key') 
            dealer.run(use_reloader=True, threaded=False, ssl_context=ctx) 

创建示例客户端

  1. Create an SSL context that will work with a self-signed certificate:

            import ssl 
            context = ssl.create_default_context(ssl.Purpose.SERVER_AUTH) 
            context.check_hostname = False 
            context.verify_mode = ssl.CERT_NONE 

    此上下文可用于所有urllib请求。这将礼貌地忽略证书上缺少 CA 签名。

    下面是我们如何使用此上下文获取 Swagger 规范:

            with urllib.request.urlopen(swagger_request, context=context) as response: 
                swagger = json.loads(response.read().decode("utf-8")) 
                pprint(swagger) 
  2. 创建用于创建新播放器实例的 URL。请注意,我们必须使用https用于方案。我们构建了一个ParseResult对象,分别显示 URL 的各个部分:

            full_url = urllib.parse.ParseResult( 
                scheme="https", 
                netloc="127.0.0.1:5000", 
                path="/dealer" + "/players", 
                params=None, 
                query=None, 
                fragment=None 
            ) 
  3. Create a Python object that will be serialized into a JSON document. This schema is similar to the example shown in the Parsing a JSON request recipe. This includes one extra property, which is the plain text:

            password.document = { 
                'name': 'Hannah Bowers', 
                'email': '[email protected]', 
                'year': 1987, 
                'twitter': 'https://twitter.com/PacktPub', 
                'password': 'OpenSesame' 
            } 

    因为 SSL 层使用加密的套接字,所以像这样发送纯文本密码是可行的。

  4. 我们将结合 URL、文档、方法和标题来创建完整的Request对象。这将使用urlunparse()将 URL 部分折叠成单个字符串。Content-Type头通知服务器我们将提供一个 JSON 表示法的文本文档:

            request = urllib.request.Request( 
                url = urllib.parse.urlunparse(full_url), 
                method = "POST", 
                headers = { 
                    'Accept': 'application/json', 
                    'Content-Type': 'application/json;charset=utf-8', 
                }, 
                data = json.dumps(document).encode('utf-8') 
            ) 
  5. We can post this document to create a new player:

            try: 
                with urllib.request.urlopen(request, context=context) as response: 
                    # print(response.status) 
                    assert response.status == 201 
                    # print(response.headers) 
                    document = json.loads(response.read().decode("utf-8")) 
    
                print(document) 
                assert document['status'] == 'ok' 
                id = document['id'] 
            except urllib.error.HTTPError as ex: 
                print(ex.status) 
                print(ex.headers) 
                print(ex.read()) 

    快乐路径将收到201状态响应,用户将被创建。响应将包括分配的用户 ID 和冗余状态代码。

    如果用户是重复的,或者文档与模式不匹配,则会引发HTTPError异常。这可能会显示有用的错误消息。

  6. We can use the assigned ID and the known password to create an Authorization header:

            import base64 
            credentials = base64.b64encode(b'75f1bfbda3a8492b74a33ee28326649c:OpenSesame') 

    Authorization头有两个字的值:b"BASIC " + credentialsBASIC一词为必填项。凭证必须是username:password字符串的base64编码。在本例中,用户名是创建用户时分配的特定 ID。

  7. 这里有一个查询所有玩家的 URL。我们构建了一个ParseResult对象来分别显示 URL 的各个部分:

            full_url = urllib.parse.ParseResult( 
                scheme="https", 
                netloc="127.0.0.1:5000", 
                path="/dealer" + "/players", 
                params=None, 
                query=None, 
                fragment=None 
            ) 
  8. 我们可以将 URL、方法和标题组合成一个Request对象。这包括Authorization头,其具有base64用户名和密码编码:

            request = urllib.request.Request( 
                url = urllib.parse.urlunparse(full_url), 
                method = "GET", 
                headers = { 
                    'Accept': 'application/json', 
                    'Authorization': b"BASIC " + credentials 
                } 
            ) 
  9. The Request object can be used to make the query from the server and process the response with urllib :

            request.urlopen(request, context=context) as response: 
                assert response.status == 200 
                # print(response.headers) 
                players = json.loads(response.read().decode("utf-8")) 
    
            pprint(players) 

    预期状态为200。响应应该是一个 JSON 文档,其中包含已知的players列表。

它是如何工作的。。。

此配方分为三个部分:

  • 使用 SSL 提供安全通道:这使得直接交换用户名和密码成为可能。我们可以使用更简单的 HTTP 基本身份验证方案,而不是更复杂的 HTTP 摘要身份验证。web 服务使用多种其他身份验证方案;其中大多数都需要 SSL。
  • 使用密码哈希最佳实践:以任何形式保存密码都存在安全风险。我们只保存密码的计算哈希值和随机 salt 字符串,而不是保存普通密码,甚至是加密密码。这使我们确信,几乎不可能从散列值中反向工程密码。
  • 使用修饰符:用于区分需要认证的路由和不需要认证的路由。这使得创建 web 服务具有很大的灵活性。

如果所有路由都需要身份验证,我们可以在@dealer.before_request函数中添加密码检查算法。这将集中所有身份验证检查。这还意味着需要一个单独的管理过程来定义用户和散列密码。

这里最重要的是,服务器上的安全检查是一个简单的@authorization_required装饰程序。很容易确保它在所有视图函数上都已就位。

还有更多。。。

此服务器有一组相对简单的授权规则:

  • 大多数路由需要有效的用户。这是通过视图函数中的@authorization_required装饰器实现的。
  • /dealer/swagger.jsonGETPOST/dealer/players不需要有效的用户。这是在没有额外的装饰器的情况下实现的。

在许多情况下,权限、组和用户的配置会相当复杂。最小特权原则建议将用户分为多个组,每个组拥有尽可能少的特权来实现其目标。

这通常意味着我们将有一个管理组来创建新用户,但没有其他访问权限来使用 RESTfulWeb 服务。用户可以访问 web 服务,但无法创建任何其他用户。

这需要对我们的数据模型进行一些更改。我们应该定义用户组并将用户分配给这些组:

    class Group: 
        '''A collection of users.''' 
        pass 

    administrators = Group() 
    players = Group() 

然后,我们可以扩展User的定义,将集团成员包括在内:

    class GroupUser(User): 
        def __init__(self, *args, **kw): 
            super().__init__(*args, **kw) 
            self.groups = set() 

当我们创建GroupUser类的新实例时,我们还可以将它们分配给特定的组:

    u = GroupUser(**document) 
    u.groups = set(players) 

现在我们可以扩展 decorator 来检查经过身份验证的用户的groups属性。带参数的装饰器比无参数的装饰器要复杂一些:

    def group_member(group_instance): 
        def group_member_decorator(view_function): 
            @wraps(view_function) 
            def decorated_view_function(*args, **kw): 
                # Check Password and determine user 
                if group_instance not in g.user.groups: 
                    abort(HTTPStatus.UNAUTHORIZED) 
                return view_function(*args, **kw) 
            return decorated_view_function 
        return group_member_decorator 

具有参数的装饰器通过创建包含该参数的具体装饰器来工作。混凝土装饰器group_member_decorator将封装给定的视图函数。这将解析Authorization头,定位GroupUser实例并检查组成员资格。

我们已经使用# Check Password and determine user作为重构函数的占位符来检查Authorization头。@authorization_required装饰器的核心功能需要被提取到一个独立的功能中,以便可以在多个地方使用。

然后,我们可以按如下方式使用此装饰器:

    @dealer.route('/dealer/players') 
    @group_member(administrators) 
    def make_player(): 
        etc. 

这将缩小每个单独视图函数的权限范围。它保证 RESTful web 服务遵循最小特权原则。

创建命令行界面

当使用具有特殊管理员权限的站点时,我们通常需要提供一种创建初始管理用户的方法。然后,该用户可以创建具有非管理权限的所有用户。这通常通过由管理用户直接在 web 服务器上运行的 CLI 应用程序来完成。

Flask 通过一个 decorator 支持这一点,该 decorator 定义了必须在 RESTfulWeb 服务环境之外运行的命令。我们可以使用@dealer.cli.command()定义从命令行运行的命令。例如,此命令可以加载初始管理用户。还可以创建一个命令从列表中加载用户。

getpass模块是管理用户提供初始密码的一种方式,不会在终端上回响。这可以确保安全地处理站点的凭据。

建立认证头

依赖 HTTP basicAuthorization头的 Web 服务可以通过以下两种常见方式之一获得支持:

  • 使用凭证构建Authorization标头,并将其包含在每个请求中。为此,我们需要为字符串username:password提供正确的base64编码。这种替代方案的优点是相对简单。

  • 使用urllib功能自动提供Authorization表头:

            from urllib.request import HTTPBasicAuthHandler,         HTTPPasswordMgrWithDefaultRealm 
            auth_handler = urllib.request.HTTPBasicAuthHandler( 
                password_mgr=HTTPPasswordMgrWithDefaultRealm) 
            auth_handler.add_password( 
                realm=None, 
                uri='https://127.0.0.1:5000/', 
                user='Aladdin', 
                passwd='OpenSesame') 
            password_opener = urllib.request.build_opener(auth_handler) 

我们已经创建了一个HTTPBasicAuthHandler的实例。这将填充可能需要的所有用户名和密码。对于从多个站点收集数据的复杂应用程序,可能会向处理程序添加多组凭据。

我们现在使用的不是with urllib.request.urlopen(request) as response:,而是with password_opener(request) as response:Authorization头由password_opener对象添加到请求中。

这种替代方案的优点是相对灵活。我们可以毫无困难地切换到使用HTTPDigestAuthHandler。我们还可以添加其他用户名和密码。

领域信息有时令人困惑。域是多个 URL 的容器。当服务器需要身份验证时,它将响应一个401状态码。此响应将包括一个Authenticate头,该头指定凭据必须属于的领域。由于领域包含多个站点 URL,因此领域信息往往是非常静态的。HTTPBasicAuthHandler使用领域和 URL 信息选择授权响应中提供的用户名和密码。

通常需要编写一个尝试连接的技术 spike,并在401响应上打印标题,以查看领域字符串是什么。一旦知道了这个领域,HTTPBasicAuthHandler就可以建立了。另一种方法是使用某些浏览器中可用的开发人员模式来检查标题并查看401响应的详细信息。

另见