专栏首页志学Pythonflask 应用程序编程接口(API)最后一节

flask 应用程序编程接口(API)最后一节

REST作为API设计的基础

有些人可能会强烈反对反对提到的/ translate和其他JSON路由是API路由。其他人可能会同意,但也会认为它们是一个设计糟糕的API。那么一个精心设计的API有什么特点,为什么上面的JSON路由不是一个好的API路由呢?

该架构中,Dr。你可能听说过REST API。REST(代表性状态转移)是Roy Fielding在博士论文中提出的一种架构。Fielding以相当抽象和通用的方式展示了REST的六个定义特征。

除了Dr.Fielding的论文外,没有关于REST的权威性规范,从而留下了许多细节供读者阅读。一个给定的API是否符合REST规范的话题往往是REST“纯粹主义者”之间激烈冲突的源头,REST“纯粹主义者”认为REST API必须以非常明确的方式遵循全部六个特征,而不像REST“实用主义者”那样,仅将Dr. Fielding站在纯粹主义阵营的一边,并在博客文章和在线评论中的撰写了一些额外的见解来表达他的目标。

包括来自Facebook,GitHub,Twitter等“大玩家”的大部分API都是如此。很少有公共API被一致认为是纯REST,因为大多数API都没有包含纯粹主义者认为必须实现的某些细节。Fielding和其他REST纯粹主义者对评判一个API是否是REST API有严格的规定,但软件行业在实际使用中引用REST是很常见的。

为了让你了解REST论文中的内容,以下各节将介绍 Fielding的六项原则。

客户端-服务器

客户端-服务器原则相当简单,预计其字面含义,在REST API中,客户端和服务器的角色应该明确区分。在实践中,这意味着客户端和服务器都是单独的进程,并在大多数情况下下,使用基于TCP网络的HTTP协议进行通信。

分级系统

分级系统原则是说当客户端需要与服务器通信时,它可能最终连接到代理服务器而不是实际的服务器。因此,对于客户端来说,如果不直接连接到服务器,它发送请求的方式应该没有同样,这个原则规定服务器兼容直接接收来自代理服务器的请求,所以它绝不能假设连接的另一端一定是客户端

这是REST的一个重要特性,因为能够添加中间节点的这个特性,允许应用程序架构师使用负载均衡器,缓存,代理服务器等来设计满足大量请求的大型复杂网络

缓存

该原则扩展了分级系统,通过明确指出了允许服务器或代理服务器缓存重新同步并且相同请求的响应内容以提高系统性能。有一个您可能熟悉的缓存实现:所有Web浏览器中的缓存。层通常为避免一遍又一遍地请求相同的文件,例如图像。

为了达到API的目的,目标服务器需要通过使用缓存控制来指示响应是否可以在代理服务器传回客户端时进行缓存。请注意,由于安全原因,部署到生产环境的API必须使用加密,因此,除非此后代理服务器终止 SSL连接,或者执行解密和重新加密,否则缓存通常不会在代理服务器中完成

按需获取客户端代码(按需编码)

这是一项可选要求,规定服务器可以提供基于服务器的代码以响应客户端,这样一来,就可以从服务器上获取客户端的新功能。因为这个原则需要服务器和客户端之间就可以客户端能够运行您可能会认为服务器可能会返回JavaScript代码以供Web浏览器客户端执行,但REST非专门针对Web浏览器客户端而设计。例如,如果客户端是iOS或Android设备,执行JavaScript可能会带来一些复杂情况

无状态

它指出,REST API可以保存客户端发送请求时的任何状态。这意味着,在Web开发中常见的在无状态API中,每个请求都需要包含服务器需要识别和验证客户端并执行请求的信息。这也意味着服务器无法在数据库或其他存储形式中存储与客户端连接有关的任何数据。

如果你想知道为什么REST需要无状态服务器,本质上是无状态服务器非常容易扩展,你只需在负载均衡器后面运行多个服务器实例即可。如果服务器存储客户端状态,则事情会变得更复杂,因为你必须弄清楚多个服务器如何访问和更新该状态,或者确保给定客户端始终由同一服务器处理,这样的机制通常称为会话

再思考一下本章介绍中讨论的/翻译路由,就会发现它不能被认为是RESTful的,因为与该路由相关的视图函数依赖于Flask-Login的@login_required装饰器,这可以将用户的登录状态存储在Flaskk用户上会话中

统一接口

最后,最重要的,最有争议的,最含糊不清的REST原则是统一接口。Dr. Fielding列出了REST统一接口的四个特性:唯一资源标识符,资源表示,自描述性消息和超媒体。

例如,与给定用户关联的URL可以是/ api / users / <user-id>,其中<user-id>是在数据库表主键中分配给用户的标识符。多数API全部很好地实现这一点。

对于大多数现代API,JSON格式用于生成资源表示。API可以选择支持多种资源表示格式,并且在这种情况下,HTTP协议中的内容协商选项是客户端和服务器确认格式的机制。

自描述性消息意味着在客户端和服务器之间交换的请求和响应必须包含对方需要的所有信息。作为一个典型的示例,HTTP请求方法用于指示客户端希望服务器执行的操作。GET请求表示客户想要检索资源信息,POST请求表示客户想要创建新资源,PUTPATCH请求定义对现有资源的修改,DELETE表示删除资源的请求。目标资源被指定为请求的URL,并在HTTP头,URL的查询字符串部分或请求主体中提供附加信息。

超媒体需求是潜在的争议性的,而且很少有API实现,而那些实现它的API很少会REST纯粹主义者的方式进行。由于应用程序中的资源都是相互关联的,因此此要求会要求将这些关系包含在资源表示中,盔甲客户端可以通过遍历关系来发现新资源,这几乎与你在Web应用程序中通过点击从一个页面到另一个页面的链接来发现新页面的方式相同。理想情况下,客户端可以输入一个API,而不需要任何有关其中的资源的信息,就可以简单地通过超媒体链接来了解它们。但是,与HTML和XML不同,通常用于API中资源表示的JSON格式没有定义包含链接的标准方式,因此您必须使用自定义结构,或者类似的JSON-API,HAL,JSON-LD这样的试图解决这种差异的JSON扩展方式。

实现API蓝图

为了让你体验开发API所涉及的内容,我将在Microblog添加API。我不会实现所有的API,只会实现与用户相关的所有功能,成为其他资源(如用户动态)的实现留给读者作为练习。

为了保持组织有序,并同时我在第十五章中描述的结构,我将创建一个包含所有API路由的新blueprint。所以,让我们从创建blueprint所在目录开始:

(venv) $ mkdir app/api

在blueprint的__init__.py文件中创建blueprint对象,这与应用程序中的其他blueprint类似:

app/api/__init__.py:API蓝图构造器。

from flask import Blueprint

bp = Blueprint('api', __name__)

from app.api import users, errors, tokens

这就是为什么app / api / users.pyapp / api / errors.pyapp / api / tokens.py模块(我还没有写)在blueprint创造之后引入的原因。

API的主要内容将存储在app / api / users.py模块中。

HTTP方法

资源网址

注释

GET

/ api / users / <id>

返回一个用户

GET

/ api /用户

返回所有用户的集合

GET

/ api / users / <id> /关注者

返回某个用户的粉丝集合

GET

/ api / users / <id> /跟随

返回某个用户关注的用户集合

POST

/ api /用户

注册一个新用户

PUT

/ api / users / <id>

修改某个用户

现在我要创建一个模块的框架,其中使用占位符来暂时填充所有的路由:

app / api / users.py:用户API资源占位符。

from app.api import bp

@bp.route('/users/<int:id>', methods=['GET'])
def get_user(id):
    pass

@bp.route('/users', methods=['GET'])
def get_users():
    pass

@bp.route('/users/<int:id>/followers', methods=['GET'])
def get_followers(id):
    pass

@bp.route('/users/<int:id>/followed', methods=['GET'])
def get_followed(id):
    pass

@bp.route('/users', methods=['POST'])
def create_user():
    pass

@bp.route('/users/<int:id>', methods=['PUT'])
def update_user(id):
    pass

app / api / errors.py模块将定义一些处理错误响应的辅助函数。但现在,我使用占位符,放在之后填充内容:

app / api / errors.py:错误处理占位符。

def bad_request():
    pass

app / api / tokens.py是将要定义的认证模块的模块。面向为非Web浏览器登录的客户端提供另一种方式。现在,我也使用占位符来处理该模块:

app / api / tokens.py:令牌处理占位符。

def get_token():
    pass

def revoke_token():
    pass

新的API blueprint需要在应用工厂函数中注册:

app/__init__.py:应用中注册API蓝图。

# ...

def create_app(config_class=Config):
    app = Flask(__name__)

    # ...

    from app.api import bp as api_bp
    app.register_blueprint(api_bp, url_prefix='/api')

    # ...

将用户表示为JSON对象

我要实现一个用户类型的API,因此我需要决定的是用户资源的表示形式。经过一番头脑风暴,产生了以下JSON表示形式:

{
    "id": 123,
    "username": "susan",
    "password": "my-password",
    "email": "susan@example.com",
    "last_seen": "2017-10-20T15:04:27Z",
    "about_me": "Hello, my name is Susan!",
    "post_count": 7,
    "follower_count": 35,
    "followed_count": 21,
    "_links": {
        "self": "/api/users/123",
        "followers": "/api/users/123/followers",
        "followed": "/api/users/123/followed",
        "avatar": "https://www.gravatar.com/avatar/..."
    }
}

许多字段直接来自用户数据库模型。password字段的特殊之处在于,它仅在注册新用户时才会使用。回顾第五章,用户密码不存储在数据库中,只存储一个散列字符串,所以密码永远不会被返回。email字段也被专门处理,因为我不想公开用户的电子邮件地址。只有当用户请求自己的条目时,报道查看才会email字段,但是当他们检索其他用户的条目时不会返回。post_countfollower_countfollowed_count这是一个很好的例子,它演示了资源表示不需要和服务器中资源的实际定义一致。

请注意_links部分,它实现了超媒体要求。API添加用户动态,那么用户的动态列表链接也应包含在这里。

JSON格式的一个好处是,它总是转换为Python字典或列表的表示形式。Python标准库中的json包负责Python数据结构和JSON之间的转换。因此,为了生成这些表示,我将在User模型中添加一个称为to_dict()的方法,该方法返回一个Python字典:

app / models.py:用户模型转换成表示。

from flask import url_for
# ...

class User(UserMixin, db.Model):
    # ...

    def to_dict(self, include_email=False):
        data = {
            'id': self.id,
            'username': self.username,
            'last_seen': self.last_seen.isoformat() + 'Z',
            'about_me': self.about_me,
            'post_count': self.posts.count(),
            'follower_count': self.followers.count(),
            'followed_count': self.followed.count(),
            '_links': {
                'self': url_for('api.get_user', id=self.id),
                'followers': url_for('api.get_followers', id=self.id),
                'followed': url_for('api.get_followed', id=self.id),
                'avatar': self.avatar(128)
            }
        }
        if include_email:
            data['email'] = self.email
        return data

该方法一目了然,只是简单地生成并返回用户表示的字典。正如我上面提到的那样,email字段需要特殊处理,因为我只想在用户请求自己的数据时才包含电子邮件。我所以使用include_email标志来确定该级别是否包含在表示中。

注意一下last_seen字段的生成。对于日期和时间字段,我将使用ISO 8601格式,Python中的datetime对象可以通过isoformat()方法生成这样格式的字符串。但是因为我使用的datetime对象的时区的英文UTC,且但没有在其状态中记录时区,所以我需要在末尾添加Z,即ISO 8601的UTC时区代码。

最后,看看我如何实现超媒体链接。对于指向应用程序其他路由的三个链接,我使用url_for()生成URL(当前指向我在app / api / users.py中定义的占位符视图函数)。头像链接是特殊的,因为它是应用外部的Gravatar URL。对于这个链接,我使用了与渲染网页中的头像的相同avatar()方法。

to_dict()方法将用户对象转换为Python表示,以后会被转换为JSON。我还需要其反向处理的方法,即客户端在请求中传递用户表示,服务器需要解析其转换为User对象。以下是实现从Python字典到User对象转换的from_dict()方法:

app / models.py:表示转换成用户模型。

class User(UserMixin, db.Model):
    # ...

    def from_dict(self, data, new_user=False):
        for field in ['username', 'email', 'about_me']:
            if field in data:
                setattr(self, field, data[field])
        if new_user and 'password' in data:
            self.set_password(data['password'])

本处我决定使用循环来导入客户端可以设置的任何字段,即usernameemailabout_me。对于每个字段,检查我是否它存在于data参数中,如果存在,我使用Python中的setattr()在对象的相应属性中设置新值。

password字段被视为特例,因为它不是对象中的字段。new_user参数确定了这是否是新的用户注册,意味着这data中所有游戏password。要在用户模型中设置密码,调用需要set_password()方法来创建³³密码哈希。

表示用户集合

例如使用客户请求用户或粉丝列表时使用的格式。以下是一组用户的表示:

{
    "items": [
        { ... user resource ... },
        { ... user resource ... },
        ...
    ],
    "_meta": {
        "page": 1,
        "per_page": 10,
        "total_pages": 20,
        "total_items": 195
    },
    "_links": {
        "self": "http://localhost:5000/api/users?page=1",
        "next": "http://localhost:5000/api/users?page=2",
        "prev": null
    }
}

在这个表示中,items是用户资源的列表,每个用户资源的定义如前一节所述。_meta部分包含集合的元数据,客户端在向用户渲染分页控件时就会用得上。_links部分定义了相关链接,包括集合本身的链接以及上一页和下一页链接,也能帮助客户端对列表进行分页。

由于分页逻辑,生成用户集合的表示很棘手,但是该逻辑对于我将来可能要添加到此API的其他资源来说是一致的,所以我将以通用的方式实现它,分解适用于其他模型。回顾第十六章,就会发现我目前的情况与全文索引类似,都是实现一个功能,还要让它可以替换任何模型。对于全文索引,我使用的解决方案是实现一个SearchableMixin类,任何需要我会故技重施,实现一个新的mixin类,我命名为PaginatedAPIMixin

app / models.py:分页表示mixin类。

class PaginatedAPIMixin(object):
    @staticmethod
    def to_collection_dict(query, page, per_page, endpoint, **kwargs):
        resources = query.paginate(page, per_page, False)
        data = {
            'items': [item.to_dict() for item in resources.items],
            '_meta': {
                'page': page,
                'per_page': per_page,
                'total_pages': resources.pages,
                'total_items': resources.total
            },
            '_links': {
                'self': url_for(endpoint, page=page, per_page=per_page,
                                **kwargs),
                'next': url_for(endpoint, page=page + 1, per_page=per_page,
                                **kwargs) if resources.has_next else None,
                'prev': url_for(endpoint, page=page - 1, per_page=per_page,
                                **kwargs) if resources.has_prev else None
            }
        }
        return data

to_collection_dict()方法产生一个带有用户集合表示的字典,包括items_meta状语从句:_links部分。你可能需要仔细检查该方法以了解其工作原理。前三个参数是烧瓶SQLAlchemy的查询对象,页码和每页数据数量。这些是决定该实现使用查询对象的paginate()方法来获取该页的压缩,就像我对主页,发现页和个人主页中的用户动态维护的一样。

我想让这个函数具有通用性,所以我不能使用类似url_for('api.get_users', id=id, page=page)这样的代码来生成自链接(译者注:因为这样的固定固定成用户资源专用了)。url_for()的参数将相应的特定资源集合,所以我将依赖于调用者在endpoint参数中传递的值,来确定需要发送到url_for()的视图函数。由于许多路由都需要参数,我还需要在kwargs中捕获更多的关键字参数,它们相互传递给url_for()pageper_page查询字符串参数是明确的通知,因为它们控制所有API路由的分页。

这个mixin类需要作为父类添加到UserModel中:

app / models.py:添加PaginatedAPIMixin到UserModel中。

class User(PaginatedAPIMixin, UserMixin, db.Model):
    # ...

将集合转换成json表示,不需要反向操作,因为我不需要客户端发送用户列表到服务器。

错误处理

我在第七章中定义的错误页面仅适用于使用Web浏览器的用户。当一个API需要返回一个错误时,它需要是一个“机器友好”的错误类型,踩客户端可以轻松解释这些错误。因此,我同样设计错误的表示为一个JSON。以下是我要使用的基本结构:

{
    "error": "short error description",
    "message": "error message (optional)"
}

为了帮助我生成这些错误响应,我将在app / api / errors.py中写入error_response()函数:除了错误的有效替代之外,我将使用HTTP协议的状态代码来指示常见错误的类型。

app / api / errors.py:错误响应。

from flask import jsonify
from werkzeug.http import HTTP_STATUS_CODES

def error_response(status_code, message=None):
    payload = {'error': HTTP_STATUS_CODES.get(status_code, 'Unknown error')}
    if message:
        payload['message'] = message
    response = jsonify(payload)
    response.status_code = status_code
    return response

该函数使用来自Werkzeug(Flask的核心依赖项)的HTTP_STATUS_CODES字典,它为每个HTTP状态代码提供一个替换的描述性名称。我在错误表示中使用这些名称作为error数组的值,所以我只需要操心数字状态码和可选的长描述。jsonify()函数返回一个默认状态码为200的瓶Response对象,因此在创建响应之后,我将状态码设置为对应的错误代码。

API将返回的最常见错误将是代码400,代表了“错误的请求”。这是客户端发送请求中包含无效数据的错误。为了更容易产生这个错误,我将为它添加一个专用函数,只以下是我之前添加的bad_request()占位符:

app / api / errors.py:错误请求的响应。

# ...

def bad_request(message):
    return error_response(400, message)

用户资源端点

必需的用户JSON表示的支持已完成,因此我已准备好开始对API端点进行编码了。

检索个别用户

让我们就从使用给定的id来检索指定用户开始吧:

app / api / users.py:返回一个用户。

from flask import jsonify
from app.models import User

@bp.route('/users/<int:id>', methods=['GET'])
def get_user(id):
    return jsonify(User.query.get_or_404(id).to_dict())

函数视图接收被请求用户的id作为URL中的动态参数。对象查询的get_or_404()方法的英文以前见过的get()方法的一个非常有用的变行业释义体育,如果用户存在,报道查看它定给id的对象,当ID不存在时,它会中止请求并向客户端返回一个404错误,而不是返回None get_or_404()get()改变优势,它不需要检查查询结果,简化了视图函数中的逻辑。

我添加到用户的to_dict()方法用于生成用户资源表示的字典,然后Flask的jsonify()函数启动字典转换为JSON格式的响应以返回给客户端。

如果您想查看第一条API路由的工作原理,请启动服务器,然后在浏览器的地址重定向输入以下URL:

http://localhost:5000/api/users/1

也可以尝试使用大一些的id值来查看SQLAlchemy查询对象的get_or_404()方法如何触发404错误(我将在以后向您演示如何扩展错误处理,踩返回这些错误JSON格式) )。

为了测试这条新路由,我将安装HTTPie,这是一个用Python 编写的命令行HTTP客户端,可以轻松发送API请求:

(venv) $ pip install httpie

我现在可以请求id1的用户(可能是你自己),命令如下:

(venv) $ http GET http://localhost:5000/api/users/1
HTTP/1.0 200 OK
Content-Length: 457
Content-Type: application/json
Date: Mon, 27 Nov 2017 20:19:01 GMT
Server: Werkzeug/0.12.2 Python/3.6.3

{
    "_links": {
        "avatar": "https://www.gravatar.com/avatar/993c...2724?d=identicon&s=128",
        "followed": "/api/users/1/followed",
        "followers": "/api/users/1/followers",
        "self": "/api/users/1"
    },
    "about_me": "Hello! I'm the author of the Flask Mega-Tutorial.",
    "followed_count": 0,
    "follower_count": 1,
    "id": 1,
    "last_seen": "2017-11-26T07:40:52.942865Z",
    "post_count": 10,
    "username": "miguel"
}

检索用户集合

要返回所有用户的集合,我现在可以依靠PaginatedAPIMixinto_collection_dict()方法:

app / api / users.py:返回所有用户的集合。

from flask import request

@bp.route('/users', methods=['GET'])
def get_users():
    page = request.args.get('page', 1, type=int)
    per_page = min(request.args.get('per_page', 10, type=int), 100)
    data = User.to_collection_dict(User.query, page, per_page, 'api.get_users')
    return jsonify(data)

对于这个实现,我首先从请求的查询字符串中提取pageper_page,如果它们没有被定义,则分别使用替换值1和10。per_page具有额外的逻辑,以100为上限。给客户端控件请求太大的页面并不是一个好主意,因为这可能会导致服务器的性能问题。然后pageper_page以及查询对象(在本例中,该查询只是User.query,是返回所有用户的最通用的查询)参数被传递给to_collection_query()方法。是api.get_users,这是我在表示中使用的三个链接所需的端点名称。

要使用HTTPie测试此端点,请使用以下命令:

(venv) $ http GET http://localhost:5000/api/users

接下来的两个endpoint是返回粉丝集合和关注用户集合。与上面的非常相似:

app / api / users.py:返回粉丝列表和关注用户列表。

@bp.route('/users/<int:id>/followers', methods=['GET'])
def get_followers(id):
    user = User.query.get_or_404(id)
    page = request.args.get('page', 1, type=int)
    per_page = min(request.args.get('per_page', 10, type=int), 100)
    data = User.to_collection_dict(user.followers, page, per_page,
                                   'api.get_followers', id=id)
    return jsonify(data)

@bp.route('/users/<int:id>/followed', methods=['GET'])
def get_followed(id):
    user = User.query.get_or_404(id)
    page = request.args.get('page', 1, type=int)
    per_page = min(request.args.get('per_page', 10, type=int), 100)
    data = User.to_collection_dict(user.followed, page, per_page,
                                   'api.get_followed', id=id)
    return jsonify(data)

由于这两条路由是特定于用户的,因此它们具有id动态参数。id用于从数据库中获取用户,然后将user.followersuser.followed关系查询提供给to_collection_dict(),所以希望现在你可以看到,花费一点点额外的时间,并以通用的方式设计该方法,对于获得的回报而言是值得的。to_collection_dict()的最后两个参数是端点名称和idid将在kwargs中作为一个额外关键字参数,然后在生成链接时将它传递给url_for()

和前面的示例类似,你可以使用HTTPie来测试这两个路由,如下所示:

(venv) $ http GET http://localhost:5000/api/users/1/followers
(venv) $ http GET http://localhost:5000/api/users/1/followed

由于超媒体,你不需要记住这些URL,因为它们包含在用户表示的_links部分。

注册新用户

/ users路由的POST请求将用于注册新的用户帐户。您可以在下面看到这条路由的实现:

app / api / users.py:注册新用户。

from flask import url_for
from app import db
from app.api.errors import bad_request

@bp.route('/users', methods=['POST'])
def create_user():
    data = request.get_json() or {}
    if 'username' not in data or 'email' not in data or 'password' not in data:
        return bad_request('must include username, email and password fields')
    if User.query.filter_by(username=data['username']).first():
        return bad_request('please use a different username')
    if User.query.filter_by(email=data['email']).first():
        return bad_request('please use a different email address')
    user = User()
    user.from_dict(data, new_user=True)
    db.session.add(user)
    db.session.commit()
    response = jsonify(user.to_dict())
    response.status_code = 201
    response.headers['Location'] = url_for('api.get_user', id=user.id)
    return response

该请求将接受请求的主体中提供的来自客户端的JSON格式的用户表示request.get_json()。Flask 提供方法从请求中提取JSON并以其作为Python结构返回。如果在请求中没有找到JSON数据,该方法返回None,那么我可以使用表达式request.get_json() or {}确保我总是可以获得一个字典。

在我可以使用这些数据之前,我需要确保我已经掌握了所有信息,因此我首先检查是否包含三个必填字段,usernameemailpassword。如果其中任何一个缺失,那么我使用应用程序/ API / errors.py模块中,bad_request()辅助函数向客户端返回一个错误。另外,我还需要确保usernameemail串联尚未被其他用户使用,因此我尝试使用获得的用户名和电子邮件从数据库中加载用户,如果返回了有效的用户,那么我也将返回错误给客户端。

一旦通过了数据验证,我可以轻松创建一个用户对象对其添加到数据库中。为了创建用户,我依赖User模型中的from_dict()方法,new_user参数被设置为True,所以它也接受通常不存在于用户表示中的password划分。

我为这个请求返回的响应将是新用户的表示,因此使用产生to_dict()它的有效格式。创建资源的POST请求的响应状态代码应该是201,即创建新实体时使用的代码。此外,HTTP协议要求201响应包含一个平均值新资源URL的Location头部。

下面你可以看到如何通过HTTPie从命令行注册一个新用户:

(venv) $ http POST http://localhost:5000/api/users username=alice password=dog \
    email=alice@example.com "about_me=Hello, my name is Alice!"

编辑用户

示例API中使用的最后一个端点用于修改已存在的用户:

app / api / users.py:修改用户。

@bp.route('/users/<int:id>', methods=['PUT'])
def update_user(id):
    user = User.query.get_or_404(id)
    data = request.get_json() or {}
    if 'username' in data and data['username'] != user.username and \
            User.query.filter_by(username=data['username']).first():
        return bad_request('please use a different username')
    if 'email' in data and data['email'] != user.email and \
            User.query.filter_by(email=data['email']).first():
        return bad_request('please use a different email address')
    user.from_dict(data, new_user=False)
    db.session.commit()
    return jsonify(user.to_dict())

一个请求到来,我通过URL收到一个动态的用户id,所以我可以加载指定的用户或返回404错误(如果发现)。就像注册新用户一样,我需要验证客户端提供的usernameemail正确性与其他用户发生了冲突,但在这种情况下,验证有点棘手。首先,这些插入在此请求中是可选的,所以我需要检查对齐是否存在。第二个复杂因素是客户端可能提供与目前绝对相同的值,所以在检查用户名或电子邮件是否被采用之前,我需要确保其与当前的不同。如果任何验证检查失败,那么我会像之前一样返回400错误给客户端。

一旦数据验证通过,我可以使用User模型的from_dict()方法导入客户端提供的所有数据,然后将更改提交到数据库。该请求的响应转换更新后的用户表示返回给用户,并使用最小的200状态代码。

以下是一个示例请求,它用HTTPie编辑about_me细分:

(venv) $ http PUT http://localhost:5000/api/users/2 "about_me=Hi, I am Miguel"

API认证

观察,执行这些操作需要认证用户才安全,从而我需要添加认证授权,简称“ AuthN”和“ AuthZ”。是,客户端发送的请求提供了某种标识,盔甲服务器知道客户端代表的是哪位用户,并且可以验证是否允许该用户执行请求的操作。

保护这些API端点的最明显的方法是使用Flask-Login中的@login_required装饰器,但是这种方法存在一些问题。装饰器检测到未通过身份验证的用户时,可以将用户重定向到HTML登录页面。API中没有HTML或登录页面的概念,如果客户端发送带有无效或所有权凭证的请求,服务器必须拒绝请求并返回401状态码。服务器无法重置API客户端是Web浏览器,或者它可以处理重定向,则其可以渲染和处理HTML登录表单。当API客户端收到401状态码时,它知道它需要向用户询问凭证,但是它是如何实现的,服务器不需要关心。

用户模型中实现令牌

对于API身份验证需求,我将使用令牌身份验证方案。当客户端想要开始与API交互时,它需要使用用户名和密码进行验证,然后获得一个临时令牌。只要令牌有效,客户端就可以发送附带token的API请求以通过认证。一旦令牌到期,需要请求新的令牌。为了支持用户令牌,我将扩展User模型:

app / models.py:支持用户令牌。

import base64
from datetime import datetime, timedelta
import os

class User(UserMixin, PaginatedAPIMixin, db.Model):
    # ...
    token = db.Column(db.String(32), index=True, unique=True)
    token_expiration = db.Column(db.DateTime)

    # ...

    def get_token(self, expires_in=3600):
        now = datetime.utcnow()
        if self.token and self.token_expiration > now + timedelta(seconds=60):
            return self.token
        self.token = base64.b64encode(os.urandom(24)).decode('utf-8')
        self.token_expiration = now + timedelta(seconds=expires_in)
        db.session.add(self)
        return self.token

    def revoke_token(self):
        self.token_expiration = datetime.utcnow() - timedelta(seconds=1)

    @staticmethod
    def check_token(token):
        user = User.query.filter_by(token=token).first()
        if user is None or user.token_expiration < datetime.utcnow():
            return None
        return user

我为用户模型添加了一个token属性,并且因为我需要通过它搜索数据库,所以我为它设置了唯一性和索引。我还添加了token_expiration替换,它保存了令牌过期的日期和时间。时间有效,以免成为安全风险。

我为它创建了一种方法来处理这些令牌。get_token()方法为用户返回一个令牌。以base64编码的24位随机字符串来生成这个令牌,将所有字符都置于串联范围内。在创建新令牌之前,此方法会检查当前分配的令牌在过期之前是否至少还剩一分钟,并且在这种情况下会返回现有的令牌。

使用令牌时,有一个策略可以立即使令牌失效总是总是一件好事,而不是仅依赖终止日期。这是一个经常被替代的安全最佳实践。revoke_token()方法始终将其分配给令牌的令牌,只需设置终止时间为当前时间的前一秒。

check_token()方法是一个静态方法,将一个令牌作为参数重置并返回此令牌所属的用户。如果令牌无效或过期,则该方法返回None

由于我对数据库进行了更改,因此需要生成新的数据库迁移,然后使用它升级数据库:

(venv) $ flask db migrate -m "user tokens"
(venv) $ flask db upgrade

带令牌的请求

当你编写一个API时,你必须考虑到你的客户端并不总是要连接到Web应用程序的Web浏览器。当独立客户端(如智能手机APP)甚至是基于浏览器的单页应用程序当这些专用客户端需要访问API服务时,他们首先需要请求令牌,对应传统的Web应用程序中登录表单的部分。

为了简化使用令牌认证时客户端和服务器之间的交互,我将使用Flask-HTTPAuth的Flask插件。Flask-HTTPAuth可以使用pip安装:

(venv) $ pip install flask-httpauth

首先,我将使用HTTP基本认证,该机制要求客户端在标准的授权中中附带用户凭证。要与Flask-HTTPAuth支持,应用需要提供这两个函数:一个用于检查用户提供的用户名和密码,另一个用于在认证失败的情况下返回错误响应。这些函数通过装饰器在Flask-HTTPAuth中注册,然后在认证流程中根据需要由插件自动调用。实现如下:

app / api / auth.py:基本认证支持。

from flask import g
from flask_httpauth import HTTPBasicAuth
from app.models import User
from app.api.errors import error_response

basic_auth = HTTPBasicAuth()

@basic_auth.verify_password
def verify_password(username, password):
    user = User.query.filter_by(username=username).first()
    if user is None:
        return False
    g.current_user = user
    return user.check_password(password)

@basic_auth.error_handler
def basic_auth_error():
    return error_response(401)

Flask-HTTPAuth的HTTPBasicAuth类实现了基本的认证流程。这两个必需的函数分别通过verify_passworderror_handler装饰器进行注册。

验证函数接收客户端提供的用户名和密码,如果凭证有效则返回True,否则返回False。我依赖User类的check_password()方法来检查密码,它在Web应用程序的认证过程中,也会被Flask-Login使用。保存在g.current_user中,盔甲我可以从API视图函数中访问它。

错误处理函数只返回由app / api / errors.py模块中的error_response()函数生成的401错误。401错误在HTTP标准中定义为“未授权”错误。HTTP客户端知道当它们收到此错误时,需要重新发送有效的凭证。

现在我已经实现了基本认证的支持,因此我可以添加一条令牌检索路由,刹车客户端在需要令牌时调用:

app / api / tokens.py:生成用户令牌。

from flask import jsonify, g
from app import db
from app.api import bp
from app.api.auth import basic_auth

@bp.route('/tokens', methods=['POST'])
@basic_auth.login_required
def get_token():
    token = g.current_user.get_token()
    db.session.commit()
    return jsonify({'token': token})

该视图函数使用了HTTPBasicAuth实例中的@basic_auth.login_required装饰器,并指示Flask-HTTPAuth验证身份(通过我上面定义的验证函数),并且仅当提供的凭证是有效的才运行下面的视图函数。于依赖模型用户的get_token()方法来生成令牌。数据库提交在生成令牌后发出,以确保令牌及其到期时间被写回到数据库。

如果您尝试直接向令牌API路由发送POST请求,则发生以下情况:

(venv) $ http POST http://localhost:5000/api/tokens
HTTP/1.0 401 UNAUTHORIZED
Content-Length: 30
Content-Type: application/json
Date: Mon, 27 Nov 2017 20:01:00 GMT
Server: Werkzeug/0.12.2 Python/3.6.3
WWW-Authenticate: Basic realm="Authentication Required"

{
    "error": "Unauthorized"
}

HTTP响应包括401状态码和我在basic_auth_error()函数中定义的错误负载。下面的请求带上了基本认证需要的凭证:

(venv) $ http --auth <username>:<password> POST http://localhost:5000/api/tokens
HTTP/1.0 200 OK
Content-Length: 50
Content-Type: application/json
Date: Mon, 27 Nov 2017 20:01:22 GMT
Server: Werkzeug/0.12.2 Python/3.6.3

{
    "token": "pC1Nu9wwyNt8VCj1trWilFdFI276AcbS"
}

现在状态码是200,这是成功请求的代码,并且有效纠正包括用户的令牌。请注意,当你发送这个请求时,你需要用你自己的凭证来替换<username>:<password>。用户名和密码需要以冒号作为分隔符。

使用令牌机制保护API路由

客户端现在可以请求一个令牌来和API端点一起使用,所以剩下的就是向这些端点添加令牌验证。Flask-HTTPAuth也可以为我处理的这些事情。我需要创建基于HTTPTokenAuth类的第二个身份验证实例,并提供令牌验证替代:

app / api / auth.py:令牌认证支持。

# ...
from flask_httpauth import HTTPTokenAuth

# ...
token_auth = HTTPTokenAuth()

# ...

@token_auth.verify_token
def verify_token(token):
    g.current_user = User.check_token(token) if token else None
    return g.current_user is not None

@token_auth.error_handler
def token_auth_error():
    return error_response(401)

使用令牌认证时,Flask-HTTPAuth使用的是verify_token装饰器注册验证函数,另外,令牌认证的工作方式与基本认证相同。我的令牌验证函数使用User.check_token()来定位令牌所属的用户。该函数还通过将当前用户设置为None来处理缺失令牌的情况。返回值是True还是False,决定了Flask-HTTPAuth是否允许视图函数的运行。

为了使用token保护API路由,需要添加@token_auth.login_required装饰器:

app / api / users.py:使用令牌认证保护用户路由。

from app.api.auth import token_auth

@bp.route('/users/<int:id>', methods=['GET'])
@token_auth.login_required
def get_user(id):
    # ...

@bp.route('/users', methods=['GET'])
@token_auth.login_required
def get_users():
    # ...

@bp.route('/users/<int:id>/followers', methods=['GET'])
@token_auth.login_required
def get_followers(id):
    # ...

@bp.route('/users/<int:id>/followed', methods=['GET'])
@token_auth.login_required
def get_followed(id):
    # ...

@bp.route('/users', methods=['POST'])
def create_user():
    # ...

@bp.route('/users/<int:id>', methods=['PUT'])
@token_auth.login_required
def update_user(id):
    # ...

请注意,装饰器被添加到除create_user()之外的所有API视图函数中,例如,此函数不能使用令牌认证,因为用户都不存在时,更不会有令牌了。

如果您直接对上面列出的受令牌保护的端点发起请求,导致得到一个401错误。为了成功访问,您需要添加Authorization标题,其值是请求/ api / tokens获得的令牌的值。Flask-HTTPAuth期望的是“不记名”令牌,但是它没有被HTTPie直接支持。就像针对基本认证,HTTPie提供了--auth选项来接受用户名和密码,但是令牌的令牌则需要显式地提供了。下面是发送不记名令牌的格式:

(venv) $ http GET http://localhost:5000/api/users/1 \
    "Authorization:Bearer pC1Nu9wwyNt8VCj1trWilFdFI276AcbS"

撤销令牌

我将要实现的最后一个令牌相关功能是令牌撤销,如下所示:

app / api / tokens.py恢复令牌。

from app.api.auth import token_auth

@bp.route('/tokens', methods=['DELETE'])
@token_auth.login_required
def revoke_token():
    g.current_user.revoke_token()
    db.session.commit()
    return '', 204

客户端可以向/令牌 URL发送DELETE请求,以使令牌失效。此路由的身份验证是基于令牌的,事实上,在Authorization头部中发送的令牌就是需要被撤销的。使用撤销了User类中的辅助方法,该方法重新设置令牌过期日期来实现还原操作。之后提交数据库会话,以确保将更改写入数据库。这个请求的响应没有正文,所以我可以返回一个空字符串。状态代码为204,该代码用于成功请求却没有响应主体的响应。

下面是取消token的一个HTTPie请求示例:

(venv) $ http DELETE http://localhost:5000/api/tokens \
    Authorization:"Bearer pC1Nu9wwyNt8VCj1trWilFdFI276AcbS"

API友好的错误消息

你是否还记得,在本章的前部分,当我要求你用一个无效的用户URL从浏览器发送一个API请求时发生了什么?服务器返回了404错误,但是这个错误被格式化为标准的404 HTML错误页面。在API蓝图中的API可能返回的许多错误可以被重写为JSON版本,但是仍然有一些错误是由Flask处理的,处理这些错误的处理函数是被大量注册到应用中的,返回的是HTML。

HTTP协议支持一种机制,通过该机制,进入和服务器可以就响应的最佳格式达成一致,称为内容协商。客户端需要发送一个Accept指针,指示格式首选项。然后,服务器查看自身格式列表并使用匹配客户端格式列表中的最佳格式进行响应。

我想做的是修改类别应用的错误处理器,使它们能够根据客户端的格式首选项对返回内容是使用HTML或JSON进行内容协商。这可以通过使用Flask的request.accept_mimetypes来完成:

app / errors / handlers.py:为错误响应进行内容协商。

from flask import render_template, request
from app import db
from app.errors import bp
from app.api.errors import error_response as api_error_response

def wants_json_response():
    return request.accept_mimetypes['application/json'] >= \
        request.accept_mimetypes['text/html']

@bp.app_errorhandler(404)
def not_found_error(error):
    if wants_json_response():
        return api_error_response(404)
    return render_template('errors/404.html'), 404

@bp.app_errorhandler(500)
def internal_error(error):
    db.session.rollback()
    if wants_json_response():
        return api_error_response(500)
    return render_template('errors/500.html'), 500

wants_json_response()如果JSON比HTML高,那么我会返回一个JSON响应。否则,我会返回原始的基于模板的HTML响应。对于JSON响应,我将使用从API blueprint中引入error_response辅助函数,但在这里我要将其重命名为api_error_response(),刹车清楚它的作用和来历

本文分享自微信公众号 - 志学Python(gh_755651538c61),作者:志学Python

原文出处及转载信息见文内详细说明,如有侵权,请联系 yunjia_community@tencent.com 删除。

原始发表时间:2019-11-29

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

我来说两句

0 条评论
登录 后参与评论

相关文章

  • 分散投资的理解

    在投资领悟,有一个我们常见的概念,就是分散投资,比如分散注意力,我们是如何才能有效的分散注意力,可以听歌,跑步,与别人交流沟通,等等

    公众号---志学Python
  • 每日一道python面试题 - Python的函数参数传递

    我们都知道,都可以理解是内存中一个对象的“引用”,或者,也可以看似c中void*,通过id来看引用a的内存地址可以比较理解:

    公众号---志学Python
  • 带你认识 flask 用户登录

    在第四章中,用户模型设置了一个password_hash字段,到目前为止还没有被使用到。这个字段的目的是保存用户密码的哈希值,并用于验证用户在登录过程中输入的密...

    公众号---志学Python
  • 给菜单拍张照,谷歌Lens告诉你什么最好吃,两百块安卓手机就能实现 | 谷歌I/O又有黑科技

    进入餐厅,拿菜单,点开大众点评/美团/Yelp等美食app,然后来回比对图片和菜单再点菜,这已经成为了一名合格吃货探索新餐厅的固定流程。

    大数据文摘
  • 利用iframe简单实现富文本效果

    其实网上有很多富文本编辑器,诸如百度的ueditor,然后markdown编辑器github也有很多:

    无道
  • 谷歌抄袭?Impact Engine公司指控谷歌侵犯其6项数字广告技术专利

    谷歌员工曾向Impact Engine公司的联合创始人表现出强烈的合作意向,因此该公司联合创始人就向谷歌的员工透露了产品原型、文档和源代码。

    镁客网
  • 把SAP云平台上创建的API proxy添加到API product里去

    Jerry Wang
  • 【Rust日报】 2019-05-10:Xi-Editor作者新博文 现代GPU上的2D图形

    這不是我們第一次遇到這個問題。而且我不確定await語法是否是我們最後一次遇到此問題。

    MikeLoveRust
  • [ALAPI上线了,一言API,二维码...]

    本文由 Alone88 创作,采用 知识共享署名4.0 国际许可协议进行许可 本站文章除注明转载/出处外,均为本站原创或翻译,转载前请务必署名 最后编辑时间为...

    Alone88
  • 犹他州空气质量分析-从EPA的空气质量服务站API中抓取数据

    住在山谷里有点像生活在汤碗里,所有重物似乎都集中在碗底。 我想说犹他州的许多山谷被称为地垒和地堑,虽然我确信一些地质学家可能纠正我的错误。无论如何,四面环山意味...

    AI研习社

扫码关注云+社区

领取腾讯云代金券