Как создать Flask API с проверкой подлинности по токенам JSON Web Token: Часть 3 из 5: Конфигурация API и регистрация пользователя

Третья часть руководства по разработке API на Flask с использованием JSON Web Token. Познакомимся с основами и требованиями REST, выбранным методом их соблюдения, приступим к созданию парсера для запросов и конечных точек API.

Источник (Aaron Luna): How To: Create a Flask API with JWT-Based Authentication Part 3: API Configuration and User Registration

  • Структура проекта
  • Введение
    • Основы REST
    • Проверка подлинности пользователя в RESTful-системе
    • Проверка подлинности токена предъявителя
    • Определение версий API
  • Конфигурация API с помощью Flask-RESTx
    • Подпроект (Blueprint) api_bp
    • Пространства имен API
  • Парсинг запросов и маршалинг ответов
    • Конфигурация парсера для запросов
    • Определение моделей API
    • Маршалинг ответов
  • Конечные точки auth_ns
  • Конечная точка api.auth_register
    • Парсер запросов auth_reqparser
    • Запрос на регистрацию процесса
    • Ресурс RegisterUser
    • Добавление пространства имен auth_ns к api
    • Модульные тесты: test_auth_register.py
  • Контрольная точка

Часть 1

Часть 2

Часть 3

Репозиторий на github для данного проекта содержит релизы с тегами для каждого раздела руководства; пользуйтесь нижеприведенными ссылками, чтобы просмотреть или загрузить код для Части 3.

Посмотреть код

Загрузить файл .zip

Загрузить файл .tar.gz

Различия между Частью 3 и Частью 2

Структура проекта

На нижеприведенной схеме приведена структура папок для данного раздела руководства. В данном посте мы будем работать со всеми файлами, помеченными как НОВЫЙ КОД. Файлы, которые содержат код из предыдущих разделов и не будут меняться в данном посте, помечены как БЕЗ ИЗМЕНЕНИЙ.

. (корневая папка проекта)
|- src
|   |- flask_api_tutorial
|       |- api
|       |   |- auth
|       |   |   |- __init__.py
|       |   |   |- business.py    <! –– НОВЫЙ КОД ––>
|       |   |   |- dto.py    <! –– НОВЫЙ КОД ––>
|       |   |   |- endpoints.py    <! –– НОВЫЙ КОД ––>
|       |   |
|       |   |- widgets
|       |   |   |- __init__.py
|       |   |
|       |   |- __init__.py    <! –– НОВЫЙ КОД ––>
|       |
|       |- models
|       |   |- __init__.py
|       |   |- user.py    <! –– БЕЗ ИЗМЕНЕНИЙ––>
|       |
|       |- util
|       |   |- __init__.py
|       |   |- datetime_util.py    <! –– БЕЗ ИЗМЕНЕНИЙ––>
|       |   |- result.py    <! –– БЕЗ ИЗМЕНЕНИЙ––>
|       |
|       |- __init__.py    <! –– НОВЫЙ КОД ––>
|       |- config.py    <! –– БЕЗ ИЗМЕНЕНИЙ––>
|
|- tests
|   |- __init__.py
|   |- conftest.py    <! –– БЕЗ ИЗМЕНЕНИЙ––>
|   |- test_auth_register.py    <! –– НОВЫЙ КОД ––>
|   |- test_config.py    <! –– БЕЗ ИЗМЕНЕНИЙ––>
|   |- test_user.py    <! –– БЕЗ ИЗМЕНЕНИЙ––>
|   |- util.py    <! –– НОВЫЙ КОД ––>
|
|- .env
|- .gitignore
|- .pre-commit-config.yaml
|- pyproject.toml
|- pytest.ini
|- README.md
|- run.py
|- setup.py
|- tox.ini

Введение

Наконец-то пора переходить к настройке API. Не забывайте, что URL-маршруты и бизнес-логику, которая исполняется при направлении пользователем запроса GET, POST, PUT или DELETE, можно реализовать с использованием функций, классов и декораторов из Flask (то есть без расширения Flask-RESTx). Однако это потребует намного больше кода и не позволит создать страницу пользовательского интерфейса Swagger для документирования и тестирования API.

Прежде чем начинать, давайте обсудим, благодаря чему REST API становится RESTful, и решим, надо ли соблюдать требования и ограничения REST.

Основы REST

Термин «REST» появился в 2000 г. и введен Роем Филдингом (Roy Fielding) в докторской диссертации под названием «Архитектурные стили и проектирование сетевых программных архитектур» (Architectural Styles and the Design of Network-based Software Architectures). Очень рекомендую попытаться полностью переварить диссертацию Филдинга. Как самый предельный минимум, нужно прочитать главу 5 «Передача репрезентативного состояния (REST)» (Representational State Transfer (REST).

Чтоб API стал RESTful, нужно гораздо больше, чем создать набор из разных URI и реализовать ответ на запросы GET, POST, PUT и т. д. Прежде всего, для REST не обязательно использовать HTTP, так как REST не зависит от протоколов. Просто так получилось, что HTTP очень популярен и хорошо подходит для систем REST. Действительно, REST — это про состояние ресурсов и того, каким образом гиперсреда определяет доступные для данных ресурсов действия. REST — это еще и про виды сред, используемые системой для представления ресурсов.

Я пишу это все потому, что большинство так называемых REST API и статей с разъяснением того, каким образом проектировать и создавать REST API, на самом деле не являются по-настоящему RESTful. В данном разделе руководства мы реализуем процессы регистрации пользователей и проверки подлинности. Как правильно проектировать REST API для выполнения данных действий? Уместны ли данные действия в REST API? Ответ не так прост и очевиден, как может показаться.

Проверка подлинности пользователя в RESTful-системе

Следующие вопросы/темы взяты со stackoverflow:

- Основы REST: глаголы, коды ошибок и проверка подлинности (Understanding REST: Verbs, error codes, and authentication)

- Как проектировать ресурсы для /login или /register в рамках правил REST? (RESTfully design /login or /register resources?)

- Какой метод HTTP следует использовать в действиях входа в систему и выхода из системы при настройке по правилам REST (Which HTTP method should Login and Logout Actions use in a "RESTful" setup)

- Как создать URL-адрес по правилам REST для входа в систему? (How to design a restful url for login?)

- Сессии и правда нарушают правила REST? (Do sessions really violate RESTfulness?)

- Проверка подлинности в рамках правил REST (RESTful Authentication)

Рекомендую просмотреть данные обсуждения. Лично я из них усвоил то, что по вопросу «правильного» способа разработки RESTful-системы проверки подлинности до консенсуса очень далеко. Структура, которую мы будем реализовывать, гарантирует, что состояние клиентского приложения никогда не будет сохраняться на сервере, соблюдающем предусмотренное REST ограничение в части отсутствия состояния. Однако структура, которую я выбрал для имен конечных точек, явно нарушает требования к порядку определения имен для URI в RESTful.

Полагаю, что нужно следовать доктрине REST, пока это имеет смысл для вашего приложения. Полагаю, что для API с функцией проверки подлинности лучшая структура будет включать в себя использование маршрутов с глаголами /register, /login и т. д. RESTful-структура, в которой все маршруты строго ссылаются на ресурс, например на /session, гораздо менее интуитивно понятна, как мне кажется.

Проверка подлинности токена предъявителя

RFC6750 представляет собой документ спецификации, которая определяет процесс авторизации, когда клиенты используют выданные сервером ресурса токены для получения доступа к защищенным ресурсам. Обмен токенами осуществляется через поля HTTP-ответа и поля заголовка. RFC6750 определяет требуемый формат и содержимое данных полей, а также рекомендуемые лучшие методы смягчения возможных угроз для безопасности.

RFC6750 — это конкретная реализация платформы авторизации OAuth 2.0. В OAuth 2.0 есть свой собственный документ спецификации RFC6749. Чтобы не дублировать работу и упростить обслуживание данных справочных документов, достаточно часто терминология, упоминаемая в спецификации Проверки подлинности токена предъявителя, ссылается на RFC6749 как на источник полного определения/объяснения. Поэтому я буду ссылаться на оба документа и воспроизводить текст каждый раз, когда буду внедрять что-нибудь для выполнения требования в целях Проверки подлинности токена предъявителя.

Я намерен придерживаться требований во всех возможных сценариях. Прошу сообщить мне, если считаете, что я реализовал что-то неправильно.

Определение версий API

Когда мы разработаем и выпустим API, важно помнить, что даже незначительные изменения, например изменение любого значения в запросе или в данных ответа со строки (string) на число (int), могут стать критическим изменением для клиентов. Избежать фрустрации лучше всего через строгое управление версиями и обещая, что при выпуске важной версии не будут вноситься никакие критические изменения.

Есть несколько подходов к управлению версиями API, но я предпочитаю самый явный: встраивание номера версии в URL-маршрут. Например, вот так выглядит API-маршрут, который регистрирует новую учетную запись пользователя: /api/v1/auth/register. Префикс /api/v1 будет добавляться ко всем API-маршрутам, и клиенты будут ожидать, что все инструменты или процессы, которые интегрируются с нашим API, будут функционировать и дальше, пока они используют тот же URI.

В данном посте на блоге SparkNotes есть неплохое краткое описание тех видов изменений, которые являются и не являются критическими:

Критические изменения (очень плохо)

- Новый обязательный параметр

- Новый обязательный ключ в POST-теле

- Удаление существующей конечной точки

- Удаление существующего метода запроса конечной точки

- Значительно отличающееся внутреннее поведение вызова API, например изменение поведения по умолчанию.

НЕ критические изменения (хорошо)

- Новый ресурс или новая конечная точка API

- Новый необязательный параметр

- Изменение непубличной конечной точки API

- Новый необязательный ключ в POST-теле JSON.

- Новый ключ, который возвращается в теле ответа JSON.

Конфигурация API с помощью Flask-RESTx

Как и любое другое расширение, Flask-RESTx можно инициализировать в объекте приложения Flask (т.е. через api.init_app(app); если так и сделать, то API будет размещаться в корне веб-сайта). Однако в большинстве приложений нам предпочтительно настроить API-маршруты с префиксом, например /api/v1, чтобы обеспечить соблюдение нашей системы управления версиями.

Для этого лучше всего использовать подпроекты (blueprint) Flask. Как правило, blueprint используется для разделения большого монолитного приложения Flask на логические группы на основе функциональных областей веб-сайта. Используя blueprint для изоляции API, мы сможем определить url_prefix для конечных точек API.

Подпроект (Blueprint) api_bp

В файле src/flask_api_tutorial/api/__init__.py добавим следующее содержимое:

"""Конфигурация подпроекта (blueprint) для API."""
from flask import Blueprint
from flask_restx import Api

api_bp = Blueprint("api", __name__, url_prefix="/api/v1")
authorizations = {"Bearer": {"type": "apiKey", "in": "header", "name": "Authorization"}}

api = Api(
    api_bp,
    version="1.0",
    title="Flask API with JWT-Based Authentication",
    description="Welcome to the Swagger UI documentation site!",
    doc="/ui",
    authorizations=authorizations,
)

Следует отметить несколько важных моментов по поводу того, как мы настроим объекты api и api_bp:

Строка 5: Здесь мы создаем blueprint-объект Flask для своего API. Первый параметр "api" — это имя для blueprint. Имена всех конечных точек API будут начинаться с данного значения (например, api.func_name). Благодаря значению url_prefix все API-маршруты начинаются с /api/v1 (например, api/v1/auth/login).

Строки 6, 14: API будет реализовывать проверку подлинности токена предъявителя. Если передать значение authorizations конструктору Api в Flask-RESTx, пользователь сможет добавлять JSON Web Token в заголовки всех запросов, направляемых через пользовательский интерфейс Swagger. Точнее говоря, пользовательский интерфейс Swagger будет содержать кнопку «Authorize», которая открывает модальное диалоговое окно, запрашивающее у пользователя значение токена доступа Bearer (предъявитель).

В настоящее время Flask-RESTx поддерживает только OpenAPI 2.0, где не предусмотрено достаточно параметров конфигурации для точного описания процесса проверки подлинности токена Bearer как объекта схемы обеспечения безопасности. В OpenAPI 3.0 все иначе. Если определить apiKey с именем Bearer, которое находится в поле Authorization заголовка запроса, то обеспечивается поведение, почти совпадающее с Проверкой подлинности токена Bearer, и предоставляется диалоговое окно на странице пользовательского интерфейса Swagger для отправки запросов с токеном доступа в заголовке.

Строка 9: Если передать blueprint-объект api_bp конструктору Api в Flask-RESTx, то эти два объекта будут связаны, и именно так все API-маршруты получают префикс со значением url_prefix из api_bp. Позже мы импортируем объект api_bp в модуль run и зарегистрируем blueprint в объекте приложения Flask, чтобы завершить процесс настройки API.

Строки 10-12: Все эти строковые значения отображаются в пользовательском интерфейсе Swagger.

Строка 13: Значение doc контролирует URL-путь пользовательского интерфейса Swagger. При наличии данного значения путь пользовательского интерфейса Swagger будет выглядеть как /api/v1/ui.

Следующим шагом настройки API является регистрация blueprint api_bp в нашем приложении Flask. Правильным местом для этого является метод create_app в файле src/flask_api_tutorial/__init__.py. Откроем данный файл и добавим выделенные ниже строки 20-22:

"""Инициализация приложения Flask через паттерн factory."""
from flask import Flask
from flask_bcrypt import Bcrypt
from flask_cors import CORS
from flask_migrate import Migrate
from flask_sqlalchemy import SQLAlchemy

from flask_api_tutorial.config import get_config

cors = CORS()
db = SQLAlchemy()
migrate = Migrate()
bcrypt = Bcrypt()


def create_app(config_name):
    app = Flask("flask-api-tutorial")
    app.config.from_object(get_config(config_name))

    from flask_api_tutorial.api import api_bp

    app.register_blueprint(api_bp)

    cors.init_app(app)
    db.init_app(app)
    migrate.init_app(app, db)
    bcrypt.init_app(app)
    return app

Место для оператора импорта выбрано специально. Для избежания циклического импорта нам нельзя импортировать пакет app.api до вызова метода create_app.

Будет правильно убедиться в том, что все по-прежнему работает, и мы ничего не сломали, поэтому запустим модульные тесты с помощью tox. Они все должны пройти успешно. Затем запустим flask route, чтобы увидеть новые URL-маршруты, зарегистрированные в нашем приложении:

(flask-api-tutorial) flask-api-tutorial $ flask routes
Endpoint             Methods  Rule
-------------------  -------  --------------------------
api.doc              GET      /api/v1/ui
api.root             GET      /api/v1/
api.specs            GET      /api/v1/swagger.json
restplus_doc.static  GET      /swaggerui/<path:filename>
static               GET      /static/<path:filename>

Первые четыре маршрута в списке добавлены с помощью регистрации blueprint api_bp в нашем приложении. Затем введем flask run, чтобы запустить сервер разработки, и введем в браузере http://localhost:5000/api/v1/ui (если вы используете другой порт или другое имя хоста на своей машине для разработки, внесите соответствующие изменения).

Нужно увидеть нечто похожее на нижеприведенный скриншот. Обратите внимание на то, что путь URL, версия API, заголовок и описание берутся непосредственно из значений, которые мы дали конструктору Api в файле src/flask_api_tutorial/api/__init__.py.

создаем api через python flask

Рисунок 1. Пользовательский интерфейс Swagger без API-маршрутов

Пространства имен API

Мы можем организовать свой Flask-RESTx API с помощью объектов пространства имен аналогично тому, как мы можем организовать свой проект Flask с помощью подпроектов (blueprints). Наш API будет содержать два пространства имен: auth_ns и widget_ns, которые соответствуют пакетам flask_api_tutorial.api.auth и flask_api_tutorial.api.widgets соответственно. На данный момент мы сосредоточимся на auth_ns, так как именно данное пространство имен обрабатывает запросы на проверку подлинности.

Сейчас папка src/flask_api_tutorial/api/auth содержит только файл __init__.py. Нам нужно создать 3 новых файла в папке auth: business.py, dto.py и endpoints.py. Запустим нижеуказанную команду из корневой папки проекта, чтобы создать файлы (или создавайте их самостоятельно, как хотите):

(flask-api-tutorial) flask-api-tutorial $ cd src/flask_api_tutorial/api/auth && touch business.py dto.py endpoints.py
(flask-api-tutorial) flask-api-tutorial/src/flask_api_tutorial/api/auth $ ls -al
total 8
drwxr-xr-x  7 aaronluna  staff  224 Dec 30 01:20 .
drwxr-xr-x  5 aaronluna  staff  160 Dec 27 02:47 ..
-rw-r--r--  1 aaronluna  staff    0 Dec 29 13:05 __init__.py
-rw-r--r--  1 aaronluna  staff    0 Dec 30 01:20 business.py
-rw-r--r--  1 aaronluna  staff    0 Dec 30 01:20 dto.py
-rw-r--r--  1 aaronluna  staff    0 Dec 30 01:20 endpoints.py

Все это файлы стандартно используются во всех моих пакетах с пространством имен Flask-RESTx API. Для данных файлов предусмотрены определенные роли, которые часто назначаются для обработки запросов и форматирования ответов:

business.py: Данный файл содержит бизнес-логику, исполняемую при получении валидного запроса конечной точкой API из данного пространства имен. (например, обработка запроса на регистрацию, обработка запроса на вход в систему и т. д.)

dto.py: DTO означает «data transfer object» (объект передачи данных). Данный файл будет содержать пользовательские объекты, с помощью которых выполняется парсинг и валидация данных из запроса, а также классы моделей API, которые будут выполнять сериализацию классов моделей из нашей базы данных в объекты JSON перед их отправкой в HTTP-ответе.

endpoints.py: Данный файл будет содержать классы Resource для Flask-RESTx. Ресурсы - самые важные отдельно взятые части любого REST API. Каждый ресурс представляет собой конечную точку API, и методы, которые мы добавляем в каждый класс Resource, управляют HTTP-методами, на которые отвечает конечная точка. Следующие имена методов автоматически сопоставляются с соответствующими методами HTTP: get, post, put, delete, patch, options и head.

Прежде чем начинать реализацию API-маршрутов и бизнес-логики для процессов проверки подлинности, нам нужно понять, как выполнять парсинг информации, которую клиент направляет в HTTP-запросе.

Парсинг запросов и маршалинг ответов

Flask-RESTx предлагает два разных подхода к парсингу и валидации данных из запроса. Решение о том, какой метод следует использовать, будет зависеть от сложности данных и интерфейса, который предоставляется клиенту.

Во многих случаях источником POST-запроса по HTTP является отправка формы со страницы. Есть еще один распространенный сценарий: GET-запрос по HTTP, когда клиент переходит по URL со строкой запроса (query string), содержащей релевантные данные из запроса. В данных случаях следует использовать класс RequestParser, предусмотренный в модуле reqparse.

Если конечная точка API ожидает сложный объект (например, пост в блоге с полным содержимым и метаданными), а источником данных запроса является НЕ веб-форма (т. е. клиент обращается к API программным путем), следует использовать модуль fields для документирования формата объекта и передачи клиенту инструкции направить объект в виде сериализованного JSON. Ожидаемый формат JSON определяется как модель API и используется для валидации данных из запроса и документирования выходного формата для объектов, возвращаемых из GET-запросов по HTTP.

Документирование ожидаемого формата данных запроса и ответа дополнительно полезно в том, пользовательский интерфейс Swagger автоматически документирует ожидаемый формат для любых указанных нами API-маршрутов.

Конфигурация парсера для запросов

Flask-RESTx предлагает класс RequestParser как способ выполнять парсинг данных из объекта Flask request. Для каждого значения, по которому нужно выполнить парсинг, мы добавляем в RequestParser экземпляр класса Argument. Класс Argument очень гибкий и настраивается с помощью нижеперечисленных параметров:

- name: Имя аргумента из запроса, для которого нужно выполнить парсинг.

- default: Значение, которое следует использовать, если аргумент не найден в запросе, по умолчанию установлено значение None.

- type: Тип, в который нужно преобразовать аргумент, для которого выполнен парсинг. Это может быть любой из простых типов (например, int, str и т. д.), но Flask-RESTx также предлагает более сложные типы в модуле inputs (например, адрес электронной почты, URL-адрес и т. д.). Еще можно определить свои пользовательские типы данных.

- required: По умолчанию, если аргументы добавляются в RequestParser и не найдены в запросе, то устанавливаются значения по умолчанию. Если required=True, то любой запрос, в котором аргумент не найден, будет прерван с исключением HTTP 400 HTTPStatus.BAD_REQUEST.

- location: Где искать аргумент в объекте Flask.request (это может быть args, form, headers, json, values или files). Поведение по умолчанию: выполнять парсинг значений из values и json. values на самом деле представляет собой словарь, который объединяет в себе args и form. Еще можно указать несколько местоположений в виде списка (например, ["form", "args"]), последнее местоположение в списке имеет приоритет в наборе результатов.

Не сомневаюсь, что данное объяснение ничего не проясняет. Полагаю, что нужно явным образом определить в объекте запроса то местоположение, где предполагается найти данные. Поскольку данное значение задокументировано в пользовательском интерфейсе Swagger, пользователям API полезно знать, как должны формироваться их запросы.

- help: Если вы предоставляете значение, оно будет вставлено в начало любого сообщения об ошибке, выдаваемого во время парсинга аргумента.

- nullable: Допустимо ли значение null или None. По умолчанию здесь True.

Рекомендую прочитать и полностью усвоить документацию, объясняющую, как определить валидацию ввода для расширенных и пользовательских типов данных. Кроме того, прошу ознакомиться с документацией API для модуля inputs, который содержит список расширенных типов данных, доступных до того, как вы создадите свой валидатор.

Определение моделей API

Для запросов POST и PUT, которые создают новый ресурс или обновляют существующий ресурс в коллекции, необходимо проинструктировать клиента направлять ресурс в виде объекта JSON в теле запроса. Можно определить ожидаемую модель API, создав объект dict, где ключами будут имена атрибутов в объекте JSON, а значениями — класс, который будет проверять и преобразовывать атрибут в требуемый тип данных.

Аналогично тому, как модуль inputs предлагает простые типы данных и набор предопределенных форматов данных для указания типа type каждого аргумента RequestParser Argument, модуль fields выполняет ту же роль для объектов model. Список предопределенных полей fields можно найти в документации API. Еще можно с легкостью создать свое пользовательское поле field, создав подкласс files.Raw, см. документацию Flask-RESTx.

Чтобы не дублировать код, то, если нужно определить две модели, представляющие один и тот же объект ORM, но предоставляющие немного различающиеся наборы атрибутов, можно создать наследование модели вместо того, чтобы два раза определять один и тот же набор полей fields.

Маршалинг ответов

Модели API можно использовать для документирования выхода любой операции API, а также ожидаемого формата запроса. Чаще всего это используется для того, чтобы возвращать представление для объекта ORM с поднабором атрибутов, определенных фактической моделью ORM. Например, класс User включает в себя атрибуты id и password_hash, в которых хранится первичный ключ для таблицы базы данных и хеш пароля, используемый для проверки пароля пользователя при входе в систему. Раскрытие этих двух значений создает очень низкий риск для безопасности, но, как минимум, они раскрывают ненужные клиенту детали реализации.

Вскоре мы увидим это в API-маршруте auth/user, который проверяет токен доступа текущего пользователя и возвращает представление объекта User в виде HTTP-ответа. Модель API, которую мы определяем как ожидаемый выход данного API-маршрута, не включает в ответ атрибуты id и password_hash.

Конечные точки auth_ns

В пространстве имен auth_ns мы создадим четыре конечных точки API, см. нижеприведенную таблицу:

Endpoint Name URL Path HTTP Method Authentication Process
api.auth_register /api/v1/auth/register POST Регистрация нового пользователя
api.auth_login /api/v1/auth/login POST Проверка подлинности пользователя
api.auth_user /api/v1/auth/user GET Получение информации о вошедшем пользователе
api.auth_logout /api/v1/auth/logout POST Добавление токена доступа в черный список

Мы реализуем каждую конечную точку по одной схеме, выполняя нижеперечисленные шаги:

1. Создадим парсеры запросов/модели API для валидации данных из запроса и сериализации данных ответа.

2. Определим бизнес-логику, необходимую для обработки запроса после успешной валидации.

3. Создадим класс, наследующий из Resource, и привяжем его к конечной точке API/URL-маршруту.

4. Определим набор HTTP-методов, которые будут поддерживаться в конечной точке API, и предоставим доступ к методам конкретного класса Resource для каждого из них. Методы с именами get, post, put, delete, patch, options или head будут вызываться при получении конечной точкой API запроса по такому же HTTP-методу.

Если конечная точка API не поддерживает HTTP-метод, не нужно предоставлять доступ к методу с именем данного HTTP-метода, и клиент получит ответ с кодом статуса 405 HTTPStatus.METHOD_NOT_ALLOWED.

5. Задокументируем класс Resource и все методы в соответствии с разъяснениями из документации Flask-RESTx. Большая часть контента на странице пользовательского интерфейса Swagger создается декораторами на ваших конкретных классах Resource и их методах.

6. Задействуем бизнес-логику, созданную на шаге 2, в соответствующих HTTP-методах, чтобы выполнять обработку запроса.

7. Создадим модульные тесты, чтобы проверять работу валидации ввода, предоставляемой парсерами запросов/моделями API, и чтобы убедиться в том, что конечная точка ведет себя как полагается.

Результатом данного процесса будет обновленная страница пользовательского интерфейса Swagger, содержащая задокументированную версию конечной точки API, которая позволяет клиентам конструировать данные в ожидаемом формате, отправлять HTTP-запросы и проверять ответы с сервера.

Конечная точка api.auth_register

В первую очередь мы создадим ресурс, который будет обрабатывать процесс регистрации новой учетной записи пользователя. Если бы у нас было руководство по полному стеку, мы бы, вероятно, создали регистрационную форму, которая вызывает данную конечную точку API, когда пользователь щелкает на кнопке «Отправить» (Submit). Все решения по поводу фронтенда я оставляю на ваше усмотрение, поэтому проектирование такой формы будет вашей задачей.

Парсер запросов auth_reqparser

Какие нужны данные, когда пытается зарегистрироваться новый пользователь? В соответствии с определением для нашей модели User значение для email должно быть уникальным (т. е. два пользователя не смогут зарегистрироваться, используя один и тот же адрес электронной почты). Помимо этого пользователь предоставляет только пароль, который не хранится в базе данных (реальный пароль нужен только для создания значения password_hash и для проверки подлинности пользователя, пытающегося войти в систему). Открываем src/flask_api_tutorial/api/auth/dto.py, добавим следующее содержимое и сохраним файл:

"""Парсеры и сериализаторы для конечных точек /auth в API."""
from flask_restx.inputs import email
from flask_restx.reqparse import RequestParser


auth_reqparser = RequestParser(bundle_errors=True)
auth_reqparser.add_argument(
    name="email", type=email(), location="form", required=True, nullable=False
)
auth_reqparser.add_argument(
    name="password", type=str, location="form", required=True, nullable=False
)

При создании экземпляра auth_reqparser в первую очередь следует обратить внимание на параметр bundle_errors=True. По умолчанию он будет иметь значение False; это означает, что каждый раз, когда данные запроса не проходят валидацию, выводится только одна ошибка. Я предпочитаю, чтобы в нашем парсере запросов все сообщения об ошибках приводились для всех аргументов.

Далее обратим внимание на то, что для аргумента email мы указали type=email(). Это предустановленный тип из Flask-RESTx, который проверяет, чтобы отправленное в запросе значение было валидным адресом электронной почты. Если запрос включает в себя значение «213323 kjljk» для email, мы предполагаем, что пользователь не будет зарегистрирован, а ответ будет содержать код статуса с указанием на ошибку валидации и сообщение, объясняющее, что значение для email не прошло валидацию.

Остальные параметры будут одинаковыми для обоих аргументов: location="form", required=True, nullable=False. Задача каждого из этих параметров уже разъяснялась ранее; данные объяснения должны ответить на все ваши вопросы о данных настройках.

Запрос на регистрацию процесса

Далее нам нужно создать функцию, которая выполняет следующие действия:

- Добавление нового пользователя в базу данных

- Выдача токена доступа для нового пользователя

- Формирование HTTP-ответа, в том числе токена доступа, и отправка ответа клиенту.

Для любого ответа, содержащего чувствительную информацию (например, токены доступа, учетные данные и т. д.), RFC6749 (OAuth 2.0) определяет обязательные и необязательные поля в теле ответа и заголовке:

5.1 Успешный ответ

Сервер авторизации выдает токен доступа и необязательный токен обновления, а также формирует ответ, добавляя следующие параметры в тело объекта HTTP-ответа:

access_token

ОБЯЗАТЕЛЬНЫЙ. Токен доступа, выданный сервером авторизации.

token_type

ОБЯЗАТЕЛЬНЫЙ. Тип токена, выданного в соответствии с описанием из Раздела 7.1. Значение нечувствительно к регистру.

expires_in

РЕКОМЕНДУЕМЫЙ. Срок действия токена доступа в секундах. Например, значение "3600" означает, что срок действия токена доступа истекает через один час после генерации ответа. Если он не добавлен, серверу авторизации НУЖНО указать время истечения срока действия другими способами или задокументировать значение по умолчанию.

refresh_token

НЕОБЯЗАТЕЛЬНЫЙ. Токен обновления, который можно использовать для получения новых токенов доступа через выдачу авторизации, описание которой приводится в разделе 6.

scope

НЕОБЯЗАТЕЛЬНЫЙ, если идентичен запрошенному клиентом контексту; в противном случае ОБЯЗАТЕЛЬНЫЙ. Контекст токена доступа, описание которого приводится в Разделе 3.3.

Параметры включаются в тело объекта HTTP-ответа с использованием типа носителя «application/json» в соответствии с [RFC4627]. Параметры сериализуются в текстовый формат обмена данными, основанный на языке JavaScript (JSON), путем добавления каждого параметра на самом высоком уровне структуры. Имена параметров и строковые значения включаются в виде строк JSON. Числовые значения включаются в виде чисел JSON. Последовательность параметров не имеет значения и может меняться.

Сервер авторизации ДОЛЖЕН включать поле заголовка HTTP-ответа «Cache-Control» [RFC2616] со значением «no-store» во все ответы, содержащие токены, учетные данные или другую чувствительную информацию, также как и поле заголовка ответа «Pragma» [RFC2616] со значением «no-cache».

Например:

HTTP/1.1 200 OK
Content-Type: application/json;charset=UTF-8
    Cache-Control: no-store
    Pragma: no-cache
    {
      "access_token": "2YotnFZFEjr1zCsicMWpAA",
      "token_type": "example",
      "expires_in": 3600,
      "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA",
      "example_parameter": "example_value"
    }

 Откроем src/flask_api_tutorial/api/auth/business.py, добавим нижеуказанное содержимое и сохраним файл:

"""Бизнес-логика для конечных точек /auth в API."""
from http import HTTPStatus

from flask import current_app, jsonify
from flask_restx import abort

from flask_api_tutorial import db
from flask_api_tutorial.models.user import User


def process_registration_request(email, password):
    if User.find_by_email(email):
        abort(HTTPStatus.CONFLICT, f"{email} is already registered", status="fail")
    new_user = User(email=email, password=password)
    db.session.add(new_user)
    db.session.commit()
    access_token = new_user.encode_access_token()
    response = jsonify(
        status="success",
        message="successfully registered",
        access_token=access_token.decode(),
        token_type="bearer",
        expires_in=_get_token_expire_time(),
    )
    response.status_code = HTTPStatus.CREATED
    response.headers["Cache-Control"] = "no-store"
    response.headers["Pragma"] = "no-cache"
    return response


def _get_token_expire_time():
    token_age_h = current_app.config.get("TOKEN_EXPIRE_HOURS")
    token_age_m = current_app.config.get("TOKEN_EXPIRE_MINUTES")
    expires_in_seconds = token_age_h * 3600 + token_age_m * 60
    return expires_in_seconds if not current_app.config["TESTING"] else 5

Строки 12-13: Первым делом мы проверяем, что предоставленный пользователем адрес электронной почты не зарегистрирован. Если объект User с таким адресом электронной почты уже существует, запрос прерывается.

Функция abort предусмотрена во Flask-RESTx и представляет собой правильный способ прерывания запроса, полученного конечной точкой API. Первый аргумент — это код HTTP-статуса, который нужно включить в ответ. В данном случае правильным кодом ответа будет 409 HTTPStatus.CONFLICT. Остальные аргументы включаются в тело ответа.

Строки 14-16: Если адрес электронной почты не зарегистрирован, мы переходим к созданию объекта User с полученными значениями электронной почты и пароля, а затем создаем коммит с новым пользователем для базы данных.

Строка 17: Ответ должен включать в себя токен доступа, и мы можем выдать его через вызов encode_access_token для объекта user.

Строка 18: Для вышеуказанной спецификации необходимо, чтобы в заголовке любого ответа, содержащего токен доступа, были поля Cache-Control и Pragma. Единственный способ добавить необходимые заголовки — это создать объект ответа самостоятельно.

Flask предлагает функцию jsonify, которая принимает словарь или список с аргументами, либо список аргументов с ключевыми словами, и преобразует данные в объект JSON (аналогично вызову json.dumps для объекта). Наконец, jsonify возвращает объект ответа с объектом JSON в качестве тела ответа.

Строка 21: В соответствии со спецификацией атрибут access_token включается в тело ответа как параметр сериализованного объекта JSON.

Строка 22: В соответствии со спецификацией атрибут token_type включается в тело ответа как параметр сериализованного объекта JSON.

Строка 23: В соответствии со спецификацией атрибут expires_in включается в тело ответа как параметр сериализованного объекта JSON.

Мы вычисляем срок действия access_token по значениям TOKEN_EXPIRE_HOURS и TOKEN_EXPIRE_MINUTES из app.config. Если установлен флаг app.config["TESTING"], в качестве срока действия токена используется пять секунд. В противном случае срок действия в секундах вычисляется по формуле TOKEN_EXPIRE_HOURS * 3600 + TOKEN_EXPIRE_MINUTES * 60.

Строка 25: Для ответа, указывающего, что мы создали новый ресурс, лучше всего подходит код HTTP-статуса 201 HTTPStatus.CREATED.

Строки 26-27: Последнее требование из вышеуказанной спецификации заключается в том, что ответ должен включать в себя поле заголовка HTTP-ответа Cache-Control со значением no-store, а также поле заголовка ответа Pragma со значением no-cache

Строка 28: Убедившись в том, что все необходимые элементы тела и заголовка ответа созданы и заполнены правильно, мы отправляем клиенту HTTP-ответ, содержащий вновь выданный access_token.

Далее нам нужно создать конечную точку API и интегрировать ее в auth_reqparser и функцию process_registration_request.

Ресурс RegisterUser

Если оглянуться назад на весь материал, рассмотренный в данном руководстве, то можно удивиться (как мне кажется) тому, что мы не написали ни одной строки кода для того, чтобы наше приложение Flask выполняло одну из базовых функций веб-сервера: Маршрутизация URL-адресов. Пора это исправить.

В приложении, которое соблюдает принципы REST, каждая конечная точка API (т.е. каждый URL-адрес) является представлением ресурса. Клиенты взаимодействуют с ресурсами, отправляя HTTP-запросы. Тип метода из запроса клиента (например, GET, PUT, POST) используется для выполнения различных операций. Именно поэтому, когда нам нужно добавить URL-маршрут в API, мы определяем класс, который наследует из базового класса flask_restx.Resource.

В соответствии с таблицей, определяющей конечные точки API для пространства имен auth_ns, пользователи могут зарегистрировать новую учетную запись, отправив запрос POST на /api/v1/auth/register. Чтобы создать данную конечную точку API, откроем src/flask_api_tutorial/api/auth/endpoints.py, добавим следующее содержимое и сохраняем файл:

"""Определения конечных точек API для пространства имен /auth."""
from http import HTTPStatus

from flask_restx import Namespace, Resource

from flask_api_tutorial.api.auth.dto import auth_reqparser
from flask_api_tutorial.api.auth.business import process_registration_request

auth_ns = Namespace(name="auth", validate=True)


@auth_ns.route("/register", endpoint="auth_register")
class RegisterUser(Resource):
    """Handles HTTP requests to URL: /api/v1/auth/register."""

    @auth_ns.expect(auth_reqparser)
    @auth_ns.response(int(HTTPStatus.CREATED), "New user was successfully created.")
    @auth_ns.response(int(HTTPStatus.CONFLICT), "Email address is already registered.")
    @auth_ns.response(int(HTTPStatus.BAD_REQUEST), "Validation error.")
    @auth_ns.response(int(HTTPStatus.INTERNAL_SERVER_ERROR), "Internal server error.")
    def post(self):
        """Register a new user and return an access token."""
        request_data = auth_reqparser.parse_args()
        email = request_data.get("email")
        password = request_data.get("password")
        return process_registration_request(email, password)

Строка 9: Объекты Namespace из Flask-RESTx используются для группировки набора связанных конечных точек API аналогично использованию объектов Blueprint из Flask для группировки связанных URL-маршрутов.

В данном файле мы будем неоднократно использовать объект auth_ns в качестве декоратора. В большинстве случаев он влияет на поведение декорированного класса или метода, но иногда — нет. У всех этих декораторов есть одна общая черта — все они создают какую-нибудь документацию на странице пользовательского интерфейса Swagger.

Данные декораторы могут информировать клиентов об ожидаемом формате данных запроса и ответа или о наборе возможных кодов HTTP-статуса, которых клиент может ожидать от сервера в ответе. Кроме того, на странице пользовательского интерфейса Swagger отображаются строки документации (docstring) для HTTP-методов, и их следует использовать для предоставления краткого описания цели метода.

Ознакомьтесь с документацией Flask-RESTx, в которой есть примеры использования декораторов для документирования страницы пользовательского интерфейса Swagger (если вам нужно еще больше информации, вероятно, она есть в документации по API).

Строка 12: Декоратор route используется для декорирования класса, наследующего из Resource. В данном случае декоратор @auth_ns.route регистрирует ресурс RegisterUser в пространстве имен auth_ns.

Первый аргумент ("/register") — это URL-маршрут, который нужно зарегистрировать. Параметр endpoint переопределяет значение по умолчанию для имени конечной точки. Мне нравится определять данное значение, чтобы соблюдать согласованную систему именования для всех конечных точек, которые входят в одно пространство имен.

Строка 13: Класс RegisterUser, который добавляет конечную точку "/register" в пространство имен auth_ns, наследуется из базового класса Resource.

Строка 16: Декоратор expect используется для указания данных, которые, как ожидает сервер, будут направлены клиентом в HTTP-запросе. Первым аргументом может быть парсер запросов или модель API, определяющая ожидаемую модель ввода. Необязательным вторым аргументом будет логическое значение с именем validate. Если validate=True, данные запроса будут проверяться на соответствие ожидаемой модели ввода.

Еще можно управлять поведением валидации для всего пространства имен; именно это мы и сделали, когда создали пространство имен auth_ns на строке 9. Еще можно определить данное поведение для всего API при создании экземпляра из объекта api или задав значение для параметра конфигурации приложения RESTPLUS_VALIDATE. Можно переопределить поведение валидации для каждого метода с помощью декоратора expect.

Мы используем auth_reqparser, который мы создали в src/flask_api_tutorial/api/auth/dto.py. Благодаря этому в пользовательском интерфейсе Swagger отображается форма с текстовыми полями для значений электронной почты и пароля, а также реализуются правила, которые мы настроили для каждого аргумента. Если бы мы использовали модель API, пользовательский интерфейс Swagger вместо этого отображал бы одно текстовое поле и пример ожидаемого JSON.

Строки 17-20: Декоратор response предназначен исключительно для документирования; удаление данных строк не влияет на поведение данной конечной точки API. Тем не менее, нужно документировать коды всех ответов, которые могут быть получены от данной конечной точки. Второй аргумент — это строковое значение, объясняющее, почему запрос клиента в итоге дал отправленный код ответа; оно добавляется на страницу пользовательского интерфейса Swagger.

Строка 21: Поскольку для данной конечной точки поддерживается только HTTP-метод POST, класс RegisterUser предоставляет доступ только к методу под названием post.

Строка 22: Данная строка документации будет отображаться на странице пользовательского интерфейса Swagger.

Строки 23-25: Чтобы получить доступ к предоставленным пользователем значениям электронной почты и пароля, мы вызываем метод parse_args на объекте auth_reqparer. Данный метод возвращает объект словаря, содержащий прошедшие валидацию аргументы.

Строка 26: Наконец, мы вызываем созданный нами метод, чтобы обработать запрос на регистрацию и получить предоставленный пользователем адрес электронной почты и пароль.

Добавление пространства имен auth_ns в api

Чтобы зарегистрировать пространство имен auth_ns в объекте api, откроем src/flask_api_tutorial/api/__init__.py и добавим выделенные строки (строка 5 и строка 19):

"""Конфигурация подпроекта (blueprint) для API."""
from flask import Blueprint
from flask_restx import Api

from flask_api_tutorial.api.auth.endpoints import auth_ns

api_bp = Blueprint("api", __name__, url_prefix="/api/v1")
authorizations = {"Bearer": {"type": "apiKey", "in": "header", "name": "Authorization"}}

api = Api(
    api_bp,
    version="1.0",
    title="Flask API with JWT-Based Authentication",
    description="Welcome to the Swagger UI documentation for the Widget API",
    doc="/ui",
    authorizations=authorizations,
)

api.add_namespace(auth_ns, path="/auth")

Параметр path в методе add_namespace задает префикс для всех конечных точек в пространстве имен auth_ns. Именно поэтому, наряду со значением url_prefix в строке 8, все URL-маршруты пространства имен auth_ns начинаются с /api/v1/auth.

Можно убедиться в том, что наш маршрут зарегистрирован правильно, запустив flask route:

flask-api-tutorial $ flask routes
Endpoint             Methods  Rule
-------------------  -------  --------------------------
api.auth_register    POST     /api/v1/auth/register
api.doc              GET      /api/v1/ui
api.root             GET      /api/v1/
api.specs            GET      /api/v1/swagger.json
restplus_doc.static  GET      /swaggerui/<path:filename>
static               GET      /static/<path:filename>

Наличие конечной точки api.auth_register в списке маршрутов подтверждает ряд аспектов:

- Ресурс RegisterUser поддерживает HTTP-запросы POST и не поддерживает никакие другие виды методов.

- RegisterUser находится в пространстве имен auth_ns.

- Поскольку объект api_bp Blueprint связан с объектом api и зарегистрирован в объекте приложения app Flask, добавление объекта auth_ns Namespace в объект api с помощью метода add_namespace автоматически регистрирует все маршруты пространства имен в объекте приложения Flask.

Запустим сервер разработки командой flask run и введем в браузере http://localhost:5000/api/v1/ui, чтобы проверить пользовательский интерфейс Swagger:

создаем api через python flask

Рисунок 2 - Пользовательский интерфейс Swagger с конечной точкой api.auth_register

Можно щелкнуть в любом месте зеленой полосы, чтобы развернуть компонент. Возможно, все это не выглядит как большое достижение, но все, что вы видите, автоматически сгенерировано Flask-RESTx (из объекта api, объекта auth_ns, auth_reqparser, RegisterUser и т. д.):

создаем api через python flask

Рисунок 3 - Развернутая конечная точка api.auth_register

Если есть желание отправить запрос, щелкните на кнопку «Try It Out». Затем введите любой реальный адрес электронной почты и любое значение для пароля и щелкните на «Execute»:

создаем api через python flask

Рисунок 4 - Конечная точка api.auth_register готова к тестированию

Вы должны получить ответ с кодом статуса 201 HTTPStatus.CREATED, если адрес электронной почты имеет правильный формат (это единственный процесс валидации, который выполняется в auth_reqparser):

создаем api через python flask

Рисунок 5 - Новый пользователь успешно зарегистрирован (пользовательский интерфейс Swagger)

Пользовательский интерфейс Swagger предлагает текстовое поле Curl с точным указанием запроса, отправленного на сервер, на основе предоставленных вами значений. cURL используется везде, можно скопировать и вставить содержимое текстового поля в любой терминал, если есть желание протестировать свой API из командной строки.

Если попробовать зарегистрироваться с адресом электронной почты, который уже есть в базе данных, мы должны получить ответ с кодом статуса 409 HTTPStatus.CONFLICT. Еще можно протестировать API с помощью инструмента командной строки (например, httpie, curl, wget и т. д.):

flask-api-tutorial $ http -f :5000/api/v1/auth/register email=This email address is being protected from spambots. You need JavaScript enabled to view it. password=123456

POST /api/v1/auth/login HTTP/1.1
Accept: */*
Accept-Encoding: gzip, deflate
Connection: keep-alive
Content-Length: 37
Content-Type: application/x-www-form-urlencoded; charset=utf-8
Host: localhost:5000
User-Agent: HTTPie/2.0.0

email=user%40test.com&password=123456

HTTP/1.0 409 CONFLICT
Access-Control-Allow-Origin: *
Content-Length: 79
Content-Type: application/json
Date: Sat, 03 Aug 2019 23:20:29 GMT
Server: Werkzeug/0.16.1 Python/3.7.6

{
  "message": "This email address is being protected from spambots. You need JavaScript enabled to view it. is already registered",
  "status": "fail",
}

В примерах с интерфейсом командной строки, которые я привожу в данном руководстве, НЕ будет использоваться cURL; я предпочитаю httpie, так как синтаксис намного чище и интуитивно понятнее. Варианты оформления и форматирования вывода - также огромный плюс.

Приведем пример успешного запроса с использованием httpie. Обратите внимание на то, что в командной строке или в пользовательском интерфейсе Swagger ответ от сервера всегда будет в формате JSON:

flask-api-tutorial $ http -f :5000/api/v1/auth/register email=This email address is being protected from spambots. You need JavaScript enabled to view it. password=123456

POST /api/v1/auth/login HTTP/1.1
Accept: */*
Accept-Encoding: gzip, deflate
Connection: keep-alive
Content-Length: 37
Content-Type: application/x-www-form-urlencoded; charset=utf-8
Host: localhost:5000
User-Agent: HTTPie/2.0.0

email=user2%40test.com&password=123456

HTTP/1.0 201 CREATED
Access-Control-Allow-Origin: *
Content-Length: 79
Content-Type: application/json
Date: Sat, 03 Aug 2019 23:20:29 GMT
Server: Werkzeug/0.16.1 Python/3.7.6

{
  "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJleHAiOjE1ODI5MzQwMzMsImlhdCI6MTU4MjkzMzEzMywic3ViIjoiNjcwOTVlZDUtZjdhYS00MGE3LTgzZGUtNzQ1YmMzYjA5NDFmIiwiYWRtaW4iOmZhbHNlfQ.ylvNfoWwhI-NRU2WS65t4ti6sTbOEDQcJYIQC6ua0Do",
  "expires_in": 900,
  "message": "successfully registered",
  "status": "success",
  "token_type": "bearer"
}

Похоже, для конечной точки /register все работает корректно. Далее разберемся, как создавать модульные тесты, взаимодействующие с API.

Модульные тесты (Unit Tests): test_auth_register.py

Перед началом работы с тестовыми сценариями для вновь созданной конечной точки нам нужно добавить функцию в tests/util.py. Откроем файл и добавим выделенные ниже строки (строки 2, 8-13):

"""Общие функции и константы для модульных тестов."""
from flask import url_for

EMAIL = "..."    # валидный адрес электронной почты
PASSWORD = "..."    # пароль


def register_user(test_client, email=EMAIL, password=PASSWORD):
    return test_client.post(
        url_for("api.auth_register"),
        data=f"email={email}&password={password}",
        content_type="application/x-www-form-urlencoded",
    )

Уделим немного времени рассмотрению функции register_user. Понимание того, как мы используем данный метод для тестирования своего API, имеет важнейшее значение для успешного завершения данного проекта:

Строка 8: Данная функция не является тестовым сценарием (так как имя не начинается с test_). register_user принимает экземпляр тестового клиента Flask и значения для email и password в качестве параметров. Всегда нужно передавать экземпляр тестового клиента при использовании данной функции, но в email и password будут использоваться значения по умолчанию, если они не указаны.

Строка 9: Тестовый клиент Flask позволяет нам выполнять HTTP-запросы. Чтобы зарегистрировать нового пользователя, мы должны отправить запрос POST на конечную точку api.auth_register. Для этого мы вызываем метод post тестового клиента. Тестовый клиент может отправлять запросы для HTTP-методов всех видов: get, post, put, delete, patch, options, head и trace.

Строка 10: В методе post первым аргументом будет URL-адрес, являющийся целью нашего запроса. Поскольку целевой URL-адрес находится в нашем приложении Flask, мы можем динамически создать URL-адрес, используя функцию url_for. Это по-настоящему полезно, так как позволяет нам создавать в своем приложении ссылки без жесткого кодирования какой-либо части пути. Чтобы использовать функцию url_for, нам просто нужно указать имя конечной точки API, и вуаля, URL-адрес генерируется динамически и передается методу post.

Строка 11: Что касается запроса POST, сервер ожидает, что данные будут отправлены в теле запроса. Поскольку мы моделируем отправку формы, нам нужно отформатировать данные в виде ряда пар имя/значение, при этом каждая пара отделяется амперсандом (&), и для каждой пары имя отделяется от значения знаком равенства (=).

Строка 12: Именно так мы указываем значение HTTP-заголовка Content-Type. Значение данного заголовка имеет большое значение, так как он сообщает серверу о типе направляемых данных. Значение application/x-www-form-urlencoded сообщает серверу, что запрос содержит данные формы, закодированные как параметры URL.

Ничего себе, здесь потребовалось гораздо больше деталей, чем я первоначально ожидал. Надеюсь, что это поможет вам разобраться в том, каким образом тестовый клиент используется в наших тестовых сценариях для взаимодействия с API. Теперь создадим новый файл test_auth_register.py в папке tests, добавим следующее содержимое и сохраним файл:

"""Модульные тесты для конечной точки api.auth_register в API."""
from http import HTTPStatus

from flask_api_tutorial.models.user import User
from tests.util import EMAIL, register_user

SUCCESS = "successfully registered"


def test_auth_register(client, db):
    response = register_user(client)
    assert response.status_code == HTTPStatus.CREATED
    assert "status" in response.json and response.json["status"] == "success"
    assert "message" in response.json and response.json["message"] == SUCCESS
    assert "token_type" in response.json and response.json["token_type"] == "bearer"
    assert "expires_in" in response.json and response.json["expires_in"] == 5
    assert "access_token" in response.json
    access_token = response.json["access_token"]
    result = User.decode_access_token(access_token)
    assert result.success
    user_dict = result.value
    assert not user_dict["admin"]
    user = User.find_by_public_id(user_dict["public_id"])
    assert user and user.email == EMAIL

В предыдущем посте я объяснил значение и задачу тестовых зафиксированных объектов app и client. Чтобы отправлять и получать HTTP-запросы от нашего API, тестовая функция должна включать в себя client в качестве параметра.

Рассмотрим функцию test_auth_register и объясним, что тестируется:

Строка 5: Импортируется функция "register_user", которую мы только что проанализировали и задокументировали.

Строка 7: Данное строковое значение будет неоднократно встречаться в тестовых сценариях, которые мы создали в данном тестовом файле, но не будет встречаться ни в каких других тестовых сценариях, поэтому нам не нужно рефакторить его и перемещать в файл "tests/util.py".

Строка 10: test_auth_register представляет собой тестовый сценарий, а client и db — тестовые зафиксированные объекты, определения для которых предусмотрены в conftest.py. Причина вызова зафиксированного объекта client очевидна — он нужен нам для тестирования API. Однако причина вызова db не столь очевидна, так как на самом деле он не вызывается в тестовой функции. Данный зафиксированный объект инициализирует базу данных, создавая таблицы для каждого класса модели базы данных (на данный момент единственным классом модели является User).

В данном тестовом сценарии мы отправляем запрос на регистрацию нового пользователя и ожидаем, что запрос будет успешным. Это сработает только в случае, если база данных инициализирована и в базе данных есть таблица site_user, так как расширение SQLAlchemy попытается выполнить SQL-оператора INSERT INTO site_user....

ЗАКЛЮЧЕНИЕ Вызывать зафиксированный объект db необходимо во всех тестовых сценариях, которые добавляют или меняют объекты базы данных.

Строка 11: Мы начинаем тестовый сценарий с направления запроса на регистрацию со значениями по умолчанию. Это действительно единственное действие, выполняемое в данном тестовом сценарии; остальная часть кода просто проверяет ответ сервера на запрос регистрации.

Строка 12: Далее мы проверяем, чтобы код статуса HTTP-ответа сервера был 201 HTTPStatus.CREATED; это означает, что в базе данных создан новый пользователь.

Строки 13-14: Эти две строки проверяют, что атрибуты status и message присутствуют в JSON-ответе и что значения указывают на успешную регистрацию пользователя.

Строка 15: Данный оператор assert проверяет присутствие атрибута token_type в JSON-ответе и значение bearer.

Строка 16: Данный оператор assert проверяет присутствие атрибута expires_in в JSON-ответе и что значение равно 5.

Строки 17-18: Затем мы проверяем присутствие access_token и извлекаем access_token.

Строки 19-22: Затем мы вызываем User.decode_access_token и проверяем, что операция прошла успешно. Далее мы извлекаем user_dict и проверяем, что токен (для пользователя, которого мы только что зарегистрировали) не имеет прав администратора.

Строки 23-24: Сразу после этого мы вызываем User.find_by_public_id со значением public_id, декодированным из access_token. Благодаря этому проверяется, что зарегистрированный нами пользователь действительно есть в базе данных. С помощью объекта из базы данных мы проверяем, совпадает ли адрес электронной почты пользователя со значением, направленным в исходном HTTP-запросе.

test_auth_register проверяет «счастливый путь» (happy path) для конечной точки api.auth_register. Очевидно, нам необходимо протестировать и сценарии, в которых запрос на регистрацию не был успешным. Прежде чем создавать следующий тестовый сценарий, обновим test_auth_register.py, импортировав значение PASSWORD из tests/util.py (строка 5), и определим новое строковое значение (строка 8):

"""Модульные тесты для конечной точки api.auth_register в API."""
from http import HTTPStatus

from flask_api_tutorial.models.user import User
from tests.util import EMAIL, PASSWORD, register_user

SUCCESS = "successfully registered"
EMAIL_ALREADY_EXISTS = f"{EMAIL} is already registered"

Чтобы проверить ожидаемое поведение при отправке запроса на регистрацию для уже зарегистрированного адреса электронной почты, добавим следующее содержимое и сохраним файл:

def test_auth_register_email_already_registered(client, db):
    user = User(email=EMAIL, password=PASSWORD)
    db.session.add(user)
    db.session.commit()
    response = register_user(client)
    assert response.status_code == HTTPStatus.CONFLICT
    assert (
        "message" in response.json and response.json["message"] == EMAIL_ALREADY_EXISTS
    )
    assert "token_type" not in response.json
    assert "expires_in" not in response.json
    assert "access_token" not in response.json

Строки 29-32: В данном тестовом сценарии мы первм делом вручную создаем экземпляр User и добавляем его в базу данных. Затем мы направляем запрос на регистрацию, совпадающий с запросом, направленным в предыдущем тестовом сценарии.

Строка 33: Поскольку в базе данных уже есть User с тем же адресом электронной почты, который направлен в запросе на регистрацию, код ответа 409 (HTTPStatus.CONFLICT) указывает на то, что выполнить запрос нельзя, но, возможно, пользователь сумеет устранить источник конфликта и повторно направить запрос.

Строки 34-37: Затем мы проверяем присутствие атрибутов "status" и "message" в объекте JSON, направленном в теле ответа, и что значение каждого из них указывает на безуспешность запроса на регистрацию.

Строки 38-40: Последние три строки проверяют, что атрибутов "token_type", "expires_in" и "access_token" нет в объекте JSON, направленном в теле ответа.

Последний тестовый сценарий, который мы рассмотрим на данном этапе: клиент направляет адрес электронной почты в неправильном формате (в финальной версии API будет больше тестовых сценариев). Давайте посмотрим на реальный ответ, который направляется в случае, когда значение электронной почты в запросе на регистрацию не является валидным адресом электронной почты (в нижеприведенном примере используется httpie):

flask-api-tutorial $ http -f :5000/api/v1/auth/register email="first last" password=123456

POST /api/v1/auth/register HTTP/1.1
Accept: */*
Accept-Encoding: gzip, deflate
Connection: keep-alive
Content-Length: 32
Content-Type: application/x-www-form-urlencoded; charset=utf-8
Host: localhost:5000
User-Agent: HTTPie/2.0.0

email=first+last&password=123456

HTTP/1.0 400 BAD REQUEST
Access-Control-Allow-Origin: *
Content-Length: 127
Content-Type: application/json
Date: Fri, 02 Aug 2019 17:45:40 GMT
Server: Werkzeug/0.16.1 Python/3.7.6

{
  "errors": {
    "email": "first last is not a valid email"
  },
  "message": "Input payload validation failed"
}

Возможно, вы заметили, что никакой код, написанный нами для конечной точки api.auth_register, не генерирует вышеприведенный ответ. Так происходит потому, что данный ответ автоматически сгенерирован Flask-RESTx на основе auth_reqparser, который мы настроили в src/flask_api_tutorial/api/auth/dto.py.

Вышеприведенный ответ НЕ имеет атрибута с именем "status", так как ответ сгенерировал Flask-RESTx, а не код, написанный для данного руководства.

Каждый раз, когда запрос отклоняется в результате того, что один или несколько аргументов RequestParser не прошли валидацию, формат ответа будет содержать атрибут "message" со значением Input payload validation failed (Проверка полезной нагрузки ввода завершилась безуспешно) и атрибут "errors" со значением в виде еще одного вложенного списка. Вложенный список содержит запись для каждого аргумента парсера, который не прошел валидацию, с указанием имени аргумента в качестве имени атрибута и значением в виде сообщения, описывающего возникший сбой.

Благодаря вышеприведенной информации написание тестового сценария должно стать несложной задачей. Поскольку сообщение об ошибке по невалидному параметру будет появляться почти в каждом наборе создаваемых нами тестовых сценариев, нам нужно добавить его в tests/util.py (строка 6):

"""Общие функции и константы для модульных тестов."""
from flask import url_for

EMAIL = "This email address is being protected from spambots. You need JavaScript enabled to view it."
PASSWORD = "test1234"
BAD_REQUEST = "Input payload validation failed"


def register_user(test_client, email=EMAIL, password=PASSWORD):
    return test_client.post(
        url_for("api.auth_register"),
        data=f"email={email}&password={password}",
        content_type="application/x-www-form-urlencoded",
    )

Нам нужно импортировать данное значение в test_auth_register.py (строка 5):

"""Модульные тесты для конечной точки api.auth_register в API."""
from http import HTTPStatus

from flask_api_tutorial.models.user import User
from tests.util import EMAIL, PASSWORD, BAD_REQUEST, register_user

SUCCESS = "successfully registered"
EMAIL_ALREADY_EXISTS = f"{EMAIL} is already registered"

Затем добавим следующее содержимое в test_auth_register.py и сохраним файл:

def test_auth_register_invalid_email(client):
    invalid_email = "first last"
    response = register_user(client, email=invalid_email)
    assert response.status_code == HTTPStatus.BAD_REQUEST
    assert "message" in response.json and response.json["message"] == BAD_REQUEST
    assert "token_type" not in response.json
    assert "expires_in" not in response.json
    assert "access_token" not in response.json
    assert "errors" in response.json
    assert "password" not in response.json["errors"]
    assert "email" in response.json["errors"]
    assert response.json["errors"]["email"] == f"{invalid_email} is not a valid email"

Думаю, здесь нечего объяснять, так как большая часть совпадает с предыдущим тестовым сценарием, и разница в JSON-ответе подробно объяснялась.

Нам нужно создать еще довольно много тестовых сценариев для конечной точки api.auth_register. Не буду вдаваться в подробности на данном этапе, так как вы можете найти полный набор в репозитории на github. Кроме того, очень полезно попробовать самостоятельно определить необходимое тестовое покрытие.

Необходимо запустить tox, чтобы убедиться в том, что все новые тестовые сценарии выполняются успешно и что изменения ничего не сломали:

(flask-api-tutorial) flask-api-tutorial $ tox
GLOB sdist-make: /Users/aaronluna/Projects/flask-api-tutorial/setup.py
py37 create: /Users/aaronluna/Projects/flask-api-tutorial/.tox/py37
py37 installdeps: black, flake8, pydocstyle, pytest, pytest-black, pytest-clarity, pytest-dotenv, pytest-flake8, pytest-flask
py37 inst: /Users/aaronluna/Projects/flask-api-tutorial/.tox/.tmp/package/1/flask-api-tutorial-0.1.zip
py37 installed: alembic==1.4.0,aniso8601==8.0.0,appdirs==1.4.3,attrs==19.3.0,bcrypt==3.1.7,black==19.10b0,certifi==2019.11.28,cffi==1.14.0,chardet==3.0.4,Click==7.0,entrypoints==0.3,flake8==3.7.9,Flask==1.1.1,flask-api-tutorial==0.1,Flask-Bcrypt==0.7.1,Flask-Cors==3.0.8,Flask-Migrate==2.5.2,flask-restx==0.1.1,Flask-SQLAlchemy==2.4.1,idna==2.9,importlib-metadata==1.5.0,itsdangerous==1.1.0,Jinja2==2.11.1,jsonschema==3.2.0,Mako==1.1.1,MarkupSafe==1.1.1,mccabe==0.6.1,more-itertools==8.2.0,packaging==20.1,pathspec==0.7.0,pluggy==0.13.1,py==1.8.1,pycodestyle==2.5.0,pycparser==2.19,pydocstyle==5.0.2,pyflakes==2.1.1,PyJWT==1.7.1,pyparsing==2.4.6,pyrsistent==0.15.7,pytest==5.3.5,pytest-black==0.3.8,pytest-clarity==0.3.0a0,pytest-dotenv==0.4.0,pytest-flake8==1.0.4,pytest-flask==0.15.1,python-dateutil==2.8.1,python-dotenv==0.12.0,python-editor==1.0.4,pytz==2019.3,regex==2020.2.20,requests==2.23.0,six==1.14.0,snowballstemmer==2.0.0,SQLAlchemy==1.3.13,termcolor==1.1.0,toml==0.10.0,typed-ast==1.4.1,urllib3==1.25.8,wcwidth==0.1.8,Werkzeug==0.16.1,zipp==3.0.0
py37 run-test-pre: PYTHONHASHSEED='1825844209'
py37 run-test: commands[0] | pytest
================================================= test session starts ==================================================
platform darwin -- Python 3.7.6, pytest-5.3.5, py-1.8.1, pluggy-0.13.1 -- /Users/aaronluna/Projects/flask-api-tutorial/.tox/py37/bin/python
cachedir: .tox/py37/.pytest_cache
rootdir: /Users/aaronluna/Projects/flask-api-tutorial, inifile: pytest.ini
plugins: clarity-0.3.0a0, black-0.3.8, dotenv-0.4.0, flask-0.15.1, flake8-1.0.4
collected 52 items

run.py::FLAKE8 PASSED                                                                                            [  1%]
run.py::BLACK PASSED                                                                                             [  3%]
setup.py::FLAKE8 PASSED                                                                                          [  5%]
setup.py::BLACK PASSED                                                                                           [  7%]
src/flask_api_tutorial/__init__.py::FLAKE8 PASSED                                                                [  9%]
src/flask_api_tutorial/__init__.py::BLACK PASSED                                                                 [ 11%]
src/flask_api_tutorial/config.py::FLAKE8 PASSED                                                                  [ 13%]
src/flask_api_tutorial/config.py::BLACK PASSED                                                                   [ 15%]
src/flask_api_tutorial/api/__init__.py::FLAKE8 PASSED                                                            [ 17%]
src/flask_api_tutorial/api/__init__.py::BLACK PASSED                                                             [ 19%]
src/flask_api_tutorial/api/auth/__init__.py::FLAKE8 PASSED                                                       [ 21%]
src/flask_api_tutorial/api/auth/__init__.py::BLACK PASSED                                                        [ 23%]
src/flask_api_tutorial/api/auth/business.py::FLAKE8 PASSED                                                       [ 25%]
src/flask_api_tutorial/api/auth/business.py::BLACK PASSED                                                        [ 26%]
src/flask_api_tutorial/api/auth/dto.py::FLAKE8 PASSED                                                            [ 28%]
src/flask_api_tutorial/api/auth/dto.py::BLACK PASSED                                                             [ 30%]
src/flask_api_tutorial/api/auth/endpoints.py::FLAKE8 PASSED                                                      [ 32%]
src/flask_api_tutorial/api/auth/endpoints.py::BLACK PASSED                                                       [ 34%]
src/flask_api_tutorial/api/widgets/__init__.py::FLAKE8 PASSED                                                    [ 36%]
src/flask_api_tutorial/api/widgets/__init__.py::BLACK PASSED                                                     [ 38%]
src/flask_api_tutorial/models/__init__.py::FLAKE8 PASSED                                                         [ 40%]
src/flask_api_tutorial/models/__init__.py::BLACK PASSED                                                          [ 42%]
src/flask_api_tutorial/models/user.py::FLAKE8 PASSED                                                             [ 44%]
src/flask_api_tutorial/models/user.py::BLACK PASSED                                                              [ 46%]
src/flask_api_tutorial/util/__init__.py::FLAKE8 PASSED                                                           [ 48%]
src/flask_api_tutorial/util/__init__.py::BLACK PASSED                                                            [ 50%]
src/flask_api_tutorial/util/datetime_util.py::FLAKE8 PASSED                                                      [ 51%]
src/flask_api_tutorial/util/datetime_util.py::BLACK PASSED                                                       [ 53%]
src/flask_api_tutorial/util/result.py::FLAKE8 PASSED                                                             [ 55%]
src/flask_api_tutorial/util/result.py::BLACK PASSED                                                              [ 57%]
tests/__init__.py::FLAKE8 PASSED                                                                                 [ 59%]
tests/__init__.py::BLACK PASSED                                                                                  [ 61%]
tests/conftest.py::FLAKE8 PASSED                                                                                 [ 63%]
tests/conftest.py::BLACK PASSED                                                                                  [ 65%]
tests/test_auth_register.py::FLAKE8 PASSED                                                                       [ 67%]
tests/test_auth_register.py::BLACK PASSED                                                                        [ 69%]
tests/test_auth_register.py::test_auth_register PASSED                                                           [ 71%]
tests/test_auth_register.py::test_auth_register_email_already_registered PASSED                                  [ 73%]
tests/test_auth_register.py::test_auth_register_invalid_email PASSED                                             [ 75%]
tests/test_config.py::FLAKE8 PASSED                                                                              [ 76%]
tests/test_config.py::BLACK PASSED                                                                               [ 78%]
tests/test_config.py::test_config_development PASSED                                                             [ 80%]
tests/test_config.py::test_config_testing PASSED                                                                 [ 82%]
tests/test_config.py::test_config_production PASSED                                                              [ 84%]
tests/test_user.py::FLAKE8 PASSED                                                                                [ 86%]
tests/test_user.py::BLACK PASSED                                                                                 [ 88%]
tests/test_user.py::test_encode_access_token PASSED                                                              [ 90%]
tests/test_user.py::test_decode_access_token_success PASSED                                                      [ 92%]
tests/test_user.py::test_decode_access_token_expired PASSED                                                      [ 94%]
tests/test_user.py::test_decode_access_token_invalid PASSED                                                      [ 96%]
tests/util.py::FLAKE8 PASSED                                                                                     [ 98%]
tests/util.py::BLACK PASSED                                                                                      [100%]

=================================================== warnings summary ===================================================
src/flask_api_tutorial/api/auth/business.py::FLAKE8
  /Users/aaronluna/Projects/flask-api-tutorial/.tox/py37/lib/python3.7/site-packages/flask_restx/model.py:12: DeprecationWarning: Using or importing the ABCs from 'collections' instead of from 'collections.abc' is deprecated since Python 3.3,and in 3.9 it will stop working
    from collections import OrderedDict, MutableMapping

src/flask_api_tutorial/api/auth/business.py::FLAKE8
  /Users/aaronluna/Projects/flask-api-tutorial/.tox/py37/lib/python3.7/site-packages/flask_restx/api.py:28: DeprecationWarning: The import 'werkzeug.cached_property' is deprecated and will be removed in Werkzeug 1.0. Use 'from werkzeug.utils import cached_property' instead.
    from werkzeug import cached_property

src/flask_api_tutorial/api/auth/business.py::FLAKE8
  /Users/aaronluna/Projects/flask-api-tutorial/.tox/py37/lib/python3.7/site-packages/flask_restx/swagger.py:12: DeprecationWarning: Using or importing the ABCs from 'collections' instead of from 'collections.abc' is deprecated since Python 3.3,and in 3.9 it will stop working
    from collections import OrderedDict, Hashable

-- Docs: https://docs.pytest.org/en/latest/warnings.html
=========================================== 52 passed, 3 warnings in 14.47s ============================================
_______________________________________________________ summary ________________________________________________________
  py37: commands succeeded
  congratulations :)

====================================

Предупреждение, генерируемое из Flask-RESTx - очень незначительная проблема, связанная с тем, каким образом один из их модулей импортирует один из типов стандартной библиотеки. Это не влияет на работу API и будет исправлено очень скоро, в следующем выпуске. Я обновлю данный результат теста, когда он будет исправлен.

Контрольная точка

И снова мы реализовали лишь небольшое количество функций из списка требований. Неудивительно, если вы осознали, что в данном разделе мы создали лишь одну из четырех конечных точек auth_ns. Думаю, мы полностью выполнили одно требование: «Новые пользователи могут зарегистрироваться, указывая адрес электронной почты и пароль» и частично выполнено еще одно, так как JSON Web Token направляется в ответе на регистрацию и вход в систему: «JSON Web Token направляется в поле access_token HTTP-ответа после успешной проверки подлинности по электронной почте/паролю».

Управление пользователями/проверка подлинности по токенам JSON Web Token

 Новые пользователи могут зарегистрироваться, указывая адрес электронной почты и пароль.

 Существующие пользователи могут получить JSON Web Token, указав свой адрес электронной почты и пароль.

 JSON Web Token предусматривает следующие сущности: время выпуска токена, время истечения срока действия токена, значение для идентификации пользователя, и флаг для указания на наличие у пользователя прав администратора.

 JSON Web Token направляется в поле access_token HTTP-ответа после успешной проверки подлинности по электронной почте/паролю.

 Срок действия токенов JSON Web Token должен истекать через 1 час (в продуктиве)

 Клиент направляет JSON Web Token в поле Authorization заголовка запроса

 Запросы следует отклонять, если JSON Web Token изменен

 Запросы следует отклонять, если истек срок действия JSON Web Token

 Если пользователь выходит из системы, его JSON Web Token сразу же утрачивает силу / истекает сроком действия

 Если истек срок действия JSON Web Token, пользователь должен повторно пройти проверку подлинности по электронной почте/паролю, чтобы получить новый JSON Web Token

Ресурс API: Список виджетов

 Все пользователи могут извлекать список всех виджетов

 Все пользователи могут извлекать отдельные виджеты по имени

 Пользователи с правами администратора могут добавлять новые виджеты в базу данных

 Пользователи с правами администратора могут редактировать существующие виджеты

 Пользователи с правами администратора могут удалять виджеты из базы данных

 Модель виджета содержит атрибут "name", который должен иметь значение строки (string), содержащей только строчные буквы, цифры и "-" (символ дефиса) или "_" (символ подчеркивания).

 Модель виджета содержит атрибут "deadline", который должен представлять собой значение datetime, в котором компонент date равен текущей дате или больше. В сравнении не учитывается значение компонента time при выполнении данного сравнения.

 Значения URL и datetime следует проверять перед добавлением нового виджета в базу данных (и при обновлении существующего виджета).

 Модель виджета содержит поле "name", которое должно иметь значение строки (string), содержащей только строчные буквы, цифры и "-" (символ дефиса) или "_" (символ подчеркивания).

 Имя виджета следует проверять перед добавлением нового виджета в базу данных (и при обновлении существующего виджета).

 Если при добавлении нового виджета или редактировании существующего виджета происходит сбой валидации ввода, ответ API должен содержать сообщения об ошибках с указанием имен полей, в которых произошел сбой валидации.

В следующем разделе мы создадим остальные три конечных точки auth_ns, поэтому весь набор требований JSON Web Token для проверки подлинности должен быть выполнен на следующей контрольной точке. Вопросы/комментарии приветствуются!