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

Масштабное руководство, которое предлагает программист Аарон Луна (Aaron Luna) из США. Проект на основе Flask с возможностью регистрации пользователей и администраторов будет создавать/менять/удалять виджеты, а проверка подлинности будет проводиться с помощью токенов Json Web Token с коротким сроком действия и автоматической инвалидацией при выходе из системы.

Источник: How To: Create a Flask API with JWT-Based Authentication Part 1: Project Setup and Environment Configuration

Содержание

  • Введение
  • Основные понятия
    • Отсутствие памяти о состоянии
    • Веб-токены JSON
  • Зависимости проекта
    • PyJWT
    • Flask-RESTx
    • Пользовательский интерфейс OpenAPI/Swagger
    • Flask-CORS
    • Flask-SQLAlchemy
    • Flask-Migrate (Alembic)
  • Зависимости в среде разработки
    • Pytest
    • Black
    • Flake8
    • Tox
  • Структура проекта
    • Создание начальных папок и файлов
    • Создание виртуальной среды
  • Файлы конфигурации
    • README.md и .gitignore
    • Файл .env
    • Конфигурация Black
    • Конфигурация прекоммита
    • Конфигурация Pytest
    • Конфигурация Tox
  • Установочный скрипт
  • Установка flask-api-tutorial
  • Пакет flask_api_tutorial.util
    • Класс Result
    • Модуль datetime_util
  • Конфигурация среды
    • python-dotenv
  • Шаблон разработки Application Factory (Фабрика приложения)
  • Модульные тесты (Unit Tests): test_config.py
  • Интерфейс командной строки Flask/точка входа в приложение
  • Контрольная точка

Часть 1

Часть 2

Часть 3

На гитхабе репозиторий данного проекта содержит релизы всех разделов с тегами. Можно использовать ссылки из нижеприведенного списка, чтобы просмотреть и загрузить код для Части 1.

Введение

В данном руководстве я хотел бы дать подробные инструкции по разработке и созданию Flask API, использующего JSON Web Token для проверки подлинности HTTP-запросов. Есть множество различных расширений для Flask и пакетов Python, которые можно использовать для создания веб-сервиса под данные требования. В нашем продукте будет использоваться цепочка инструментов с Flask-RESTx, SQLAlchemy, PyJWT, pytest и tox (это просто мое личное предпочтение).

У нас НЕ учебник полного стека, т.е. создание фронта интерфейса для потребления API не предусмотрено. Но Flask-RESTx будет автоматически генерировать веб-страницу пользовательского интерфейса Swagger, которая позволяет каждому направлять запросы и проверять ответы из API.

В дополнение к функциям управления пользователями и проверки подлинности API будет содержать ресурс RESTful, которым зарегистрированные пользователи могут манипулировать с помощью действий CRUD (создание/чтение/обновление/удаление); речь идет о списке «виджетов». Почему я решил создавать виджеты, а не элементы списка задач или что-то реальное? Использование ресурса общего характера усиливает идею о том, что данный код - шаблонный, и его можно легко адаптировать для использования в реальном API.

Выполнение действий CRUD и ограничение доступа на основе назначенных пользователям ролей/разрешений - очень распространенное требование, и код для данной задачи одинаковый для виджета, блог-поста или любого другого варианта, который вы открываете клиентам через HTTP.

Технические условия функций API приводятся далее. Надеюсь, различные методологии и «лучшие практики», которые я покажу, хорошо обоснованы и оправданы аргументами, которые я для них приведу. Приветствуются любые комментарии/критика; не стесняйтесь регистрировать задачи в github-репозитории для предлагаемых улучшений и (или) любых ошибок, которые я пропустил.

В конце каждого раздела все полностью выполненные требования будут помечаться иконкой (☆):

Управление пользователями/проверка подлинности по 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: Список виджетов

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

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

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

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

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

☆ Модель виджета содержит атрибуты с типами данных URL, datetime, timedelta и bool, а также обычные текстовые поля.

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

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

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

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

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

Основные понятия

Важно понимать историю и реальное значение термина REST, а также структуру и назначение веб-токенов JSON. Давайте рассмотрим данные темы, прежде чем начнем работать над приложением.

Отсутствие памяти о состоянии

Я принял сознательное решение НЕ обозначать данную серию постов как учебник по REST API. Создается впечатление, что в последние несколько лет все API и все статьи с практическими сведениями по проектированию API провозглашают себя RESTful. Данная тенденция обесценивает то, с какой глубиной и сложностью Рой Филдинг изложил свою докторскую диссертацию, в которой представлена и описана архитектура REST. Подробнее я рассмотрю данную тему в Части 3, когда мы начнем настраивать API.

Но думаю будет важно указать, где конкретно я пытаюсь придерживаться требований/ограничений REST. Одно из данных ограничений – отсутствие памяти о состоянии. Это важная характеристика системы RESTful, но поначалу данная концепция может сбивать с толку.

Очевидно, что и клиент, и сервер в любой гипотетической системе сохраняют состояние; они просто поддерживают состояния разных видов. Например, веб-браузер отслеживает каждую посещенную веб-страницу, а также текущую страницу; в системе RESTful это называется состоянием приложения. Если веб-сайт представляет собой банковское приложение, то сервер, на котором размещен веб-сайт, отслеживает открытые и измененные банковские счета; это называют состоянием ресурса. Под отсутствием памяти состояния подразумевается, что сервер не интересуется состоянием клиентского приложения, и поэтому на сервере не должны храниться данные о состоянии клиентского приложения.

Это создает очевидные последствия для сценариев проверки подлинности, так как в системе RESTful сервер не хранит никакой информации о том, какие пользователи находятся в системе в конкретный момент времени. Поэтому для доступа к защищенному ресурсу клиент должен включать в каждый запрос информацию для проверки подлинности. Чтобы не включать пароль клиента в каждый запрос, существует распространенная практика, в рамках которой сервер генерирует токен доступа после верификации учетных данных пользователя. После этого, когда клиент направит запрос на доступ к защищенному ресурсу, токен включается в заголовок запроса и проверяется сервером. Наиболее распространенным форматом токенов авторизации является веб-токен JSON Web Token, который мы рассмотрим в следующем разделе.

Веб-токены JSON

JSON Web Token – открытый стандарт IETF, в котором определяется компактный и автономный способ безопасной передачи информации между сторонами в виде объекта JSON. JSON Web Token состоят из трех частей: заголовок, полезная нагрузка и подпись. Они преобразуются в безопасную для URL строку в кодировке base64 и объединяются. Каждая часть отделена символом «.» (символ точки).

Заголовок будет идентифицировать объект как JSON Web Token, а также идентифицировать алгоритм, используемый для генерации подписи (например, {"typ": "JWT", "alg": "HS256"}). См. далее процесс преобразования в безопасную для URL строку с кодировкой base64:

ASCII: {"t  yp"  :"J  WT"  ,"a  lg"  :"H  S25  6"}
URLSAFE-B64: eyJ0 eXAi OiJK V1Qi LCJh bGci OiJS UzI1 NiJ9

Полезная нагрузка состоит из различных сущностей, которые представляют собой пары ключ/значение с информацией о пользователе и самом ключе. Есть множество заранее определенных сущностей (их называют зарегистрированными сущностями), но можно создавать и свои.

Обычно полезная нагрузка содержит время выпуска токена и время истечения его срока действия. Это зарегистрированные сущности, идентифицированные по iat и exp соответственно. Значения datetime следует обозначать как «секунды с начала эпохи», и в python есть встроенные функции для преобразования объектов datetime в данный числовой формат. Однако пакет PyJWT позаботится об этом преобразовании при создании токена.

Еще одна зарегистрированная сущность – это sub (субъект), которая предназначена для представления элемента, которому выпущен токен. Когда пользователь регистрируется через API, генерируется случайное значение UUID, которое сохраняется в базе данных и будет использоваться в качестве значения для sub .

Пример полезной нагрузки, содержащей данные три сущности: {"sub": "570eb73b-b4b4-4c86-b35d-390b47d99bf6", "exp": 1555873759, "iat": 1555872854}. См. далее преобразование в безопасную для URL строку с кодировкой base64:

ASCII: {"s  ub"  :"5  70e  b73  b-b  4b4  -4c  86-  b35  d-3  90b  47d  99b  f6"  ,"e  xp"  :15  558  737  59,  "ia  t":  155  587  285  4}
URLSAFE-B64: eyJz dWIi OiI1 NzBl Yjcz Yi1i NGI0 LTRj ODYt YjM1 ZC0z OTBi NDdk OTli ZjYi LCJl eHAi OjE1 NTU4 NzM3 NTks Imlh dCI6 MTU1 NTg3 Mjg1 NH0=

Криптографическая подпись рассчитывается по заголовку и полезной нагрузке, что гарантирует отсутствие изменений в информации из обеих частей. См. далее преобразование в безопасную для URL строку с кодировкой base64:

HEX: c88b51 cb57fc 521fff 0baf19 162dba b7d3e6 c2395b 90512b 1f1847 4f3ec5 672e
URLSAFE-B64: yItR   y1f8   Uh__   C68Z   Fi26   t9Pm   wjlb   kFEr   HxhH   Tz7F   Zy4=

Если их объединить в JSON Web Token, получится следующий токен:

eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiI1NzBlYjczYi1iNGI0LTRjODYtYjM1ZC0zOTBiNDdkOTliZjYiLCJleHAiOjE1NTU4NzM3NTksImlhdCI6MTU1NTg3Mjg1NH0.yItRy1f8Uh__C68ZFi26t9PmwjlbkFErHxhHTz7FZy4

Пакет PyJWT обрезает все символы заполнения ("=") из компонентов JSON Web Token. В полезной нагрузке и подписи изначально был один такой символ, которого нет в вышеуказанной итоговой версии.

Строки в кодировке Base64 могут выглядеть чепухой, но НЕ совершайте ошибку, предполагая, что данные в полезной нагрузке зашифрованы. НИКОГДА не добавляйте никакие конфиденциальные данные (например, пароль пользователя, информация о платеже) в полезную нагрузку JSON Web Token, так как ее легко может декодировать кто угодно.

Зависимости проекта

В Python мне больше всего нравится то, что для приложений или библиотек любых видов, которые могут понадобиться, уже все создано и доступно через pip. Когда дело доходит до инструментов для создания REST API и JSON Web Token, доступен головокружительный диапазон возможностей. Я хотел бы сделать краткий обзор наиболее важных пакетов и расширений Flask, которые мы будем использовать в этом проекте.

PyJWT

PyJWT – пакет, который мы будем использовать для генерации и декодирования веб-токенов JSON.

Flask-RESTx

Flask-RESTx – расширение Flask, которое упрощает создание API-интерфейсов (на самом деле, большую часть настройки можно выполнить с помощью декораторов). Данное расширение предоставляет полезные инструменты для преобразования данных из пользовательских объектов Python в соответствующий формат для отправки в виде HTTP-ответа. Как и следовало ожидать, также есть инструменты для парсинга данных из HTTP-запросов на базовые и пользовательские типы данных Python. Но моя любимая функция – это визуальная интерактивная документация, которая автоматически генерируется с помощью пользовательского интерфейса Swagger.

Пользовательский интерфейс OpenAPI/Swagger

Инициатива OpenAPI (OAI) – организация, целью которой является создание единого формата для документирования сервисов API. Формат OpenAPI изначально был известен как спецификация Swagger. Пользовательский интерфейс Swagger – чрезвычайно полезный инструмент, который генерирует веб-страницу из спецификации OpenAPI/Swagger, предоставляя визуальную документацию для вашего API, которая позволяет кому угодно тестировать ваши методы API, создавать запросы, проверять ответы и т.д.

Flask-CORS

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

Flask-SQLAlchemy

Flask-SQLAlchemy – расширение Flask, которое добавляет поддержку SQLAlchemy и упрощает интеграцию ORM с приложением Flask. Если вы не встречались с SQLAlchemy, нижеприведенное описание из официальной документации представляет собой идеальное резюме:

"SQLAlchemy Object Relational Mapper предлагает метод для создания связи между определенными пользователем классами Python и таблицами базы данных, а также между экземплярами данных классов (объектов) – со строками в соответствующих таблицах. Оно включает в себя систему, которая прозрачно синхронизирует все изменения состояния между объектами и связанными с ними строками; это называют элементарной операцией; а также систему для выражения запросов к базе данных в терминах определяемых пользователем классов и определенных в них отношений между собой".

Согласен, звучит как магия. Еще одна ключевая особенность SQLALchemy в том, что вид используемой базы данных (MySQL, SQLite, PostgreSQL и т.д.) практически не имеет значения (данный аспект становится актуальным только при необходимости использовать функцию, которая поддерживается только определенным внутренним интерфейсом). Например, можно настроить свой API на использование базы данных PostgreSQL в продуктиве и использовать простой файл SQLite в качестве внутреннего интерфейса в средах тестирования и разработки. Тогда не будет необходимости менять код для поддержки каждой конфигурации; и это снова звучит как магия.

Flask-Migrate (Alembic)

Alembic – инструмент миграции базы данных, созданный автором SQLAlchemy, а Flask-Migrate – расширение для Flask, которое добавляет операции Alembic в интерфейс командной строки Flask. Миграция базы данных – набор изменений в схему базы данных (например, добавление новой таблицы, обновление отношений через внешний ключ и т.д.), аналогичный коммиту в системе контроля версий. Во Flask-Migrate каждая миграция представлена ​​в виде скрипта с операторами SQL, что позволяет «обновить» базу данных для применения изменений в схеме или «отката обновления» и отмены изменений. Благодаря этому процесс развертывания изменений в базе данных в продуктиве становится безопасным и простым; просто создаем скрипт миграции после тестирования и проверки изменений, а затем запускаем скрипт миграции в продуктиве, чтобы применить изменения.

Зависимости в среде разработки

Скрипт установки для нашего приложения позволит пользователю устанавливать зависимости, которые необходимы только для запуска комплекса тестов и (или) участия в разработке приложения. Это очень распространенный вариант для приложений Python, и именно так мы будем устанавливать приложение, чтобы убедиться в том, что тестовые сценарии для кода у нас выполняются аналогично процессу установки конечным пользователем.

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

Параметр установки [dev] для нашего проекта установит средство форматирования кода, линтер, инфраструктуру модульного тестирования, несколько плагинов для pytest и пакет проверки перед коммитами, который автоматически запустит средство форматирования кода для всех измененных файлов. Далее по каждому из данных инструментов я объясню, почему я выбрал именно данный инструмент/пакет.

Pytest

Я отдаю большое предпочтение pytest как тестовой среде. По сравнению со встроенной библиотекой unittest (или другими фреймворками, например nose), pytest почти не требует шаблонного кода (например, наследования из TestCase) и полагается исключительно на встроенного оператора assert для верификации ожидаемого поведения. Напротив, с помощью unittest потребуется изучить новый API с несколькими разными методами, чтобы создавать утверждения "assert" для одного и того же выражения (например, self.assertEqual, self.assertFalse, self.assertIsNotNone и т. д.).

Еще одна особенность pytest – это зафиксированные объекты (fixtures). Зафиксированные объекты бывают чрезвычайно сложными, но в простейшем случае они представляют собой просто функцию, которая создает и возвращает тестовый объект (например, функция с именем db, которая возвращает мок-объект базы данных). Зафиксированный объект создается путем добавления декоратора к функции с помощью @pytest.fixture:

@pytest.fixture
def db():
    return MockDatabase()

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

def test_new_user(db, email, password):
    new_user = User(email=email, password=password)
    db.session.add(new_user)
    db.session.commit()
    user_exists = User.query.filter_by(email=email).first()
    assert user_exists

При выполнении данного теста pytest обнаружит и вызовет зафиксированный объект с именем db, давая доступ к мок-объекту базы данных в тестовом сценарии. Данный метод отделения кода теста от объектов, необходимых для выполнения кода теста, представляет собой пример внедрения зависимости.

Black

Black – мое любимое средство для форматирования кода. Если сравнивать с YAPF или autopep8, black умышленно самостоятелен и предлагает очень мало вариантов конфигурации. С другими инструментами форматирования придется потратить время на настройку конфигурации, пока не будет получен желаемый формат. При использовании black я настраиваю только один параметр: максимальная длина строки (повышаю ее с 79 до 89).

Благодаря этому вы получите дополнительную пользу, если сотрудничаете с кем-то еще в работе над базой кода; ведь сложнее обеспечить единообразие стиля/формата, если все используют разные индивидуальные настройки в autopep8. Последовательность стиля на всем протяжении проекта позволит вашей команде повысить продуктивность, ведь меньше времени будет тратиться на соответствие стилю, а код будет легче восприниматься визуально.

Flake8

Flake8 – мой любимый линтер кода. Несмотря на то, что black переформатирует код, он никогда не меняет его поведение (black проверяет AST на отсутствие модификаций, прежде чем применить любые изменения). На самом деле, Flake8 представляет собой оболочку для трех различных инструментов статического анализа: pydocstyle (проверяет на соответствие правилам форматирования PEP8 аналогично black, но строже), PyFlakes (проверяет на ошибки программирования, которые можно было бы обнаружить только во время выполнения) и mccabe (проверяет цикломатическую сложность).

Если множество способов настройки Flake8, поэтому для получения от него максимальной отдачи потребуется уделить время. При правильном применении flake8 сделает ваш код более читаемым, менее подверженным ошибкам и более удобным в обслуживании. Позднее в данном руководстве мы рассмотрим мои любимые настройки flake8.

Tox

Tox – очень мощный инструмент, который можно использовать в качестве единой точки входа для различных действий во время сборки, тестирования и выпуска релиза. Наиболее распространенный вариант использования tox – проверка процесса установки проекта и выполнение произвольных команд (например, модульные тесты) в изолированных виртуальных средах. Данный аспект очень важен, если нужно поддерживать несколько версий Python, и очень полезно, ведь tox автоматизирует то, что в противном случае было бы утомительным и требующим внимания процессом.

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

Очень важно правильно определить расположение тестового кода относительно приложения. Есть несколько допустимых способов компоновки проекта, а плюсы и минусы различных вариантов компоновки собраны в виде полезного набора передовых практик на сайте документации pytest. Конкретные рекомендации, которые я применил к данному проекту, приведены ниже:

  • Поместите файл setup.py в корневую папку проекта. Скоро мы рассмотрим содержимое данного файла, так как он позволяет установить приложение с помощью pip.
  • Поместите тестовый код в отдельную папку вне кода приложения.
    • Это позволяет запускать тесты для установленной версии приложения после выполнения pip install или pip install -e.
    • Флаг -e устанавливает приложение в редактируемом режиме, что позволяет запускать тесты в локальном экземпляре разработки кода. Благодаря этому не придется переустанавливать приложение каждый раз, когда вносятся изменения, ведь тесты будут выполняться на измененном коде.

Помимо данных требований в этом проекте я использую структуру с изолированной папкой src. Папка src важна тем, что она не является пакетом Python (т.е. не содержит файл __init__.py). Папка src расположена в корне проекта и содержит только одну папку под названием flask_api_tutorial. Данная папка представляет собой пакет Python и будет содержать весь код нашего приложения.

Корень проекта также будет содержать папку tests, файл setup.py для установки приложения, файлы конфигурации, лицензию, README и т.д. Вот наглядное представление, которое поможет вам, если я непонятно объясняю:

. (корневая папка проекта)
|- src
|   |- flask_api_tutorial
|       |- __init__.py
|       |- ...
|
|- tests
|   |- __init__.py
|   |- ...
|
|- setup.py
|- README.md
|- ...

Такое структурирование проекта будет полезно в нескольких аспектах. Наиболее очевидным является то, что вы вынуждены устанавливать приложение через pip для запуска тестов. Это гарантирует, что скрипт setup.py правильно развертывает приложение, позволяя сразу находить и устранять любые проблемы.

Пост Python Packaging Ионела Кристиана Марьеша содержит отличный аргумент в пользу компоновки с src. Статья Testing & Packaging Хайнека Шлавака поновее, в ней тоже аргументируется использование компоновки с src. Очень рекомендую полностью прочитать оба блог-поста.

Создание начальных папок и файлов

Не забывая об этих рекомендациях, давайте начнем с создания компоновки папки для приложения (а также создадим пустые файлы __init__.py для каждого пакета Python).

Можно как угодно назвать свою корневую папку (ниже ее отображает узел верхнего уровня «.»), или можно быть как я и использовать название flask_api_tutorial. В большинстве проектов, где используется структура с папкой src, корневая папка и папка с кодом приложения в папке src будут называться одинаково.

В данном разделе мы будем работать со всем, что помечено как NEW CODE (НОВЫЙ КОД) в нижеприведенной таблице (на данном этапе все файлы будут пустыми):

. (корневая папка проекта)
|- src
|   |- flask_api_tutorial
|       |- api
|       |   |- auth
|       |   |   |- __init__.py
|       |   |
|       |   |- widgets
|       |   |   |- __init__.py
|       |   |
|       |   |- __init__.py
|       |
|       |- models
|       |   |- __init__.py
|       |
|       |- util
|       |   |- __init__.py
|       |   |- datetime_util.py <! –– NEW CODE ––>
|       |   |- result.py <! –– NEW CODE ––>
|       |
|       |- __init__.py <! –– NEW CODE ––>
|       |- config.py <! –– NEW CODE ––>
|
|- tests
|   |- __init__.py
|   |- test_config.py <! –– NEW CODE ––>
|
|- .env # NEW CODE
|- .gitignore <! –– NEW CODE ––>
|- .pre-commit-config.yaml <! –– NEW CODE ––>
|- pyproject.toml <! –– NEW CODE ––>
|- pytest.ini <! –– NEW CODE ––>
|- README.md <! –– NEW CODE ––>
|- run.py <! –– NEW CODE ––>
|- setup.py <! –– NEW CODE ––>
|- tox.ini <! –– NEW CODE ––>

Ничто не мешает создавать структуру проекта вручную или через командную строку, см. далее:

~ $ mkdir flask_api_tutorial && cd flask_api_tutorial
flask-api-tutorial $ mkdir src && cd src
flask-api-tutorial/src $ mkdir flask_api_tutorial && cd flask_api_tutorial && touch __init__.py
flask-api-tutorial/src/flask_api_tutorial $ mkdir api && cd api && touch __init__.py
flask-api-tutorial/src/flask_api_tutorial/api $ mkdir auth && cd auth && touch __init__.py
flask-api-tutorial/src/flask_api_tutorial/api/auth $ cd ..
flask-api-tutorial/src/flask_api_tutorial/api $ mkdir widgets && cd widgets && touch __init__.py
flask-api-tutorial/src/flask_api_tutorial/api/widgets $ cd ../..
flask-api-tutorial/src/flask_api_tutorial $ mkdir models && cd models && touch __init__.py
flask-api-tutorial/src/flask_api_tutorial/models $ cd ..
flask-api-tutorial/src/flask_api_tutorial $ mkdir util && cd util && touch __init__.py
flask-api-tutorial/src/flask_api_tutorial/util $ cd ../../..
flask-api-tutorial $ mkdir tests && cd tests && touch __init__.py
flask-api-tutorial/tests $ cd ..
flask-api-tutorial $

Создание виртуальной среды

Далее создаем новую виртуальную среду любым удобным способом (для данного проекта требуется Python 3.6+). Я использую pyenv для управления несколькими установками Python, так как различные проекты должны поддерживать разные версии или несколько версий, а также должны тестироваться на них. Быстрое и простое руководство по настройке и использованию pyenv можно найти в данной статье на Hacker Noon.

Даже если вы не используете pyenv, процесс создания и активации виртуальной среды будет аналогичен нижеприведенным шагам:

flask-api-tutorial $ python --version
Python 2.7.14
flask-api-tutorial $ pyenv local 3.7.6
flask-api-tutorial $ python --version
Python 3.7.5
flask-api-tutorial $ python -m venv venv --prompt flask-api-tutorial
flask-api-tutorial $ source venv/bin/activate
(flask-api-tutorial) flask-api-tutorial $

После активации новой виртуальной среды обновите pip, setuptools и wheel:

(flask-api-tutorial) flask-api-tutorial $ pip install --upgrade pip setuptools wheel
# removed package upgrade messages...
Successfully installed pip-20.0.2 setuptools-45.2.0 wheel-0.34.2

Наконец, инициализируем новый репозиторий git для нашего проекта:

flask-api-tutorial) flask-api-tutorial $ git init
Initialized empty Git repository in /Users/aaronluna/Projects/flask-api-tutorial

Файлы конфигурации

Если вы знакомы с экосистемой Python, то, вероятно, заметили, что корневая папка любого проекта, более сложного, чем список дел, содержит различные файлы конфигурации, README.md, файл лицензии, requirements.txt и т. д. К сожалению, в данном проекте все будет то же самое. Давайте разберемся с этим прямо сейчас.

На данном этапе рекомендую переключиться на любимое IDE для разработки на Python. Я большой поклонник VSCode, поэтому буду использовать именно его.

README.md и .gitignore

Создаем два пустых файла в корневой папке проекта: один с именем README.md, а второй с именем .gitignore. Для данного проекта можно копировать версии файлов из репозитория github. Не привожу пример для копирования и вставки, так как многие, как правило, очень индивидуально подходят к включению файлов/папок в свой файл .gitignore. Используемая мной версия настроена на основе данного примера файла .gitignore для проектов Python из официального репозитория github.

Файл .env

Создаем файл с именем .env в корневой папке проекта и добавляем нижеуказанные значения. Сохраняем файл:

FLASK_APP=run.py
FLASK_ENV=development
SECRET_KEY="please change me"

FLASK_APP представляет собой путь к файлу (или путь для импорта модуля), где находится объект приложения Flask (см. более подробную информацию про FLASK_APP).

Во FLASK_ENV возможны только два значения: development и production. Если установить FLASK_ENV=development, включается интерактивный отладчик и автоматический перезагрузчик файлов (подробнее про FLASK_ENV).

Параметр SECRET_KEY будет использоваться для подписания наших токенов авторизации JSON. Значение, которое вы выбираете для данного ключа, должно быть длинной случайной строкой байтов. Очень важно не раскрывать данное значение, так как любой, кто знает данное значение, может генерировать ключи авторизации для вашего API. Рекомендуемый способ генерации SECRET_KEY – использовать интерпретатор Python:

(flask-api-tutorial) flask-api-tutorial $ python
Python 3.7.6 (default, Jan 19 2020, 06:08:58)
[Clang 11.0.0 (clang-1100.0.33.8)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> import os
>>> os.urandom(24)
b'\x1ah\xe9\x00\x04\x1d>\x00\x14($\x17\x90\x1f?~?\xdc\xe9\x91U\xd2\xb5\xd7'

Обновим файл .env, чтобы использовать сгенерированное случайное значение. Сохраняем изменения:

FLASK_APP=run.py
FLASK_ENV=development
SECRET_KEY="\x1ah\xe9\x00\x04\x1d>\x00\x14($\x17\x90\x1f?~?\xdc\xe9\x91U\xd2\xb5\xd7"

Конфигурация Black

Прежде чем перейти к коду приложения, давайте настроим правила для black. Создаем файл с именем pyproject.toml в корневой папке проекта и добавляем следующее содержимое:

[tool.black]
line-length = 89
target-version = ['py37']
include = '\.pyi?$'
exclude =  '''
/(
    \.eggs
    | \.git
    | \.hg
    | \.mypy_cache
    | \.pytest_cache
    | \.tox
    | \.vscode
    | __pycache__
    | _build
    | buck-out
    | build
    | dist
    | venv
)/
'''

Мне нравится увеличивать максимальную длину строки до 89. Специалисты, которые поддерживают проект black, рекомендуют длину строки примерно 90, но вам лучше использовать любую наиболее удобную для вас длину строки. target-version определяет, на какие версии Python должен быть нацелен код, отформатированный в black. include и exclude представляют собой регулярные выражения, которые сопоставляются с файлами и папками для форматирования в black и исключения из форматирования.

Конфигурация прекоммита

Наверное будет полезно иметь возможность убедиться в том, что весь код коммита отформатирован с использованием black до того, как изменения будут направлены на удаленный сервер? В этом случае в проекте никогда не будет кода с разными стилями форматирования; все будет красиво и последовательно.

Это и правда просто сделать с помощью хуков git. Доступно много разных хуков, но нас интересует хук для прекоммита. Можно пользоваться данным хуком, добавляя скрипт для определения необходимости отклонить коммит или выдать разрешение на продолжение.

К счастью, разработчики пакета pre-commit все уже сделали за нас, и мы его вскоре установим. Чтобы запустить black на всех файлах, включенных в коммит, создаем новый файл с именем .pre-commit-config.yaml в корневой папке проекта и добавляем следующее содержимое:

repos:
  - repo: https://github.com/psf/black
    rev: stable
    hooks:
      - id: black
        language_version: python3.7

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

Конфигурация Pytest

Прежде чем переходить к коду для тестов, давайте настроим в pytest отчеты для результатов тестов и некоторые из установленных плагинов. Создаем файл под названием pytest.ini в корневой папке проекта и вставляем следующее:

[pytest]
addopts =
    # generate report with details of all (non-pass) test results
    -ra
    # show local variables in tracebacks
    --showlocals
    # report formatting changes suggested by black
    --black
    # report linting issues with flake8
    --flake8
    # verbose output
    --verbose
norecursedirs =
    .git
    .pytest_cache
    .vscode
    migrations
    venv
flake8-max-line-length = 89
flake8-ignore = E203, E266, E501, W503
flake8-max-complexity = 18

Очевидно, что в данный файл мы добавляем много решений по конфигурации. Обратите внимание на следующее:

Строка 2: Параметр конфигурации addopts добавляет указанные опции к набору аргументов командной строки каждый раз, когда пользователь запускает pytest. Другими словами, если addopts = -ra --showlocals, выполнение команды pytest test_config.py на самом деле выполнит pytest -ra --showlocals test_config.py.

Строка 4: Флаг -r генерирует раздел «short test summary info» (краткая сводная информация о тесте) в конце сеанса теста, что упрощает просмотр всех неудачных результатов теста. Флаг -a означает «все, кроме удачных тестов».

Строка 6: Флаг --showlocals добавляет все значения локальных переменных в трассировку всех неудачных тестов.

Строка 8: Флаг --black сообщает об изменениях форматирования, которые предложил black. Данный параметр доступен только благодаря тому, что мы установим плагин pytest-black как требование для разработки.

Строка 10: Флаг --flake8 сообщает об изменениях структуры кода, которые предложил flake8. Данный параметр доступен только благодаря тому, что мы установим плагин pytest-flake8 как требование разработки.

Строка 12: Для параметра --verbose пояснений требоваться не должно, я предпочитаю включать подробный вывод для всех результатов тестов.

Строки 13-18: Параметр конфигурации norecursedirs указывает pytest не искать код тестов в папках из указанного списка. Благодаря этому pytest работает намного быстрее, так как значительно сокращается общее количество мест поиска.

Строка 19: Вышеуказанные и все параметры конфигурации, которые начинаются с flake8, распространяются только на плагин pytest-flake8. Параметр flake8-max-line-length установлен на 89, чтобы обеспечить соблюдение того правила по стилю, которое я настроил в своей конфигурации black.

Строка 20: Параметр flake8-ignore указывает pytest-flake8 никогда не включать в отчет случаи нарушения указанного правила. Данный список скопирован из flake8 config settings in black, который подавляет данные ошибки, так как предусмотренные в них правила нарушают PEP8.

Строка 21: Параметр flake8-max-complexity устанавливает допустимый порог цикломатической сложности. Если какая-то функция будет сложнее указанного значения, в результатах теста появится ошибка flake8.

Конфигурация Tox

Последний нужный нам файл конфигурации – это Tox. Мы используем tox в основном потому, что он позволяет нам правильно протестировать структуру проекта с папкой src. На данный момент мы будем использовать очень простую конфигурацию. Создаем новый файл с именем tox.ini в корневой папке проекта и добавляем следующее содержимое:

k19

[tox]
envlist = py37

[testenv]
deps =
    black
    flake8
    pydocstyle
    pytest
    pytest-black
    pytest-clarity
    pytest-dotenv
    pytest-flake8
    pytest-flask

commands = pytest

Данный файл указывает tox, что нужно установить пакеты, перечисленные в deps (а также наше приложение) в новой изолированной виртуальной среде под Python 3.7, и выполнить одну команду: pytest.

Вот и все нужные нам файлы конфигурации! Однако нам нужно создать еще один файл в корневой папке проекта.

Установочный скрипт

Далее создаем новый файл с именем setup.py в корневой папке проекта и добавляем следующее содержимое. Затем сохраняем и закрываем файл:

"""Установочный скрипт для приложения flask-api-tutorial."""
from pathlib import Path
from setuptools import setup, find_packages

DESCRIPTION = (
    "Boilerplate Flask API with Flask-RESTx, SQLAlchemy, pytest, flake8, "
    "tox configured"
)
APP_ROOT = Path(__file__).parent
README = (APP_ROOT / "README.md").read_text()
AUTHOR = "Aaron Luna"
AUTHOR_EMAIL = "This email address is being protected from spambots. You need JavaScript enabled to view it."
PROJECT_URLS = {
    "Documentation": "https://aaronluna.dev/series/flask-api-tutorial/",
    "Bug Tracker": "https://github.com/a-luna/flask-api-tutorial/issues",
    "Source Code": "https://github.com/a-luna/flask-api-tutorial",
}
INSTALL_REQUIRES = [
    "Flask",
    "Flask-Bcrypt",
    "Flask-Cors",
    "Flask-Migrate",
    "flask-restx",
    "Flask-SQLAlchemy",
    "PyJWT",
    "python-dateutil",
    "python-dotenv",
    "requests",
    "urllib3",
    "werkzeug==0.16.1",
]
EXTRAS_REQUIRE = {
    "dev": [
        "black",
        "flake8",
        "pre-commit",
        "pydocstyle",
        "pytest",
        "pytest-black",
        "pytest-clarity",
        "pytest-dotenv",
        "pytest-flake8",
        "pytest-flask",
        "tox",
    ]
}

setup(
    name="flask-api-tutorial",
    description=DESCRIPTION,
    long_description=README,
    long_description_content_type="text/markdown",
    version="0.1",
    author=AUTHOR,
    author_email=AUTHOR_EMAIL,
    maintainer=AUTHOR,
    maintainer_email=AUTHOR_EMAIL,
    license="MIT",
    url="https://github.com/a-luna/flask-api-tutorial",
    project_urls=PROJECT_URLS,
    packages=find_packages(where="src"),
    package_dir={"": "src"},
    python_requires=">=3.6",
    install_requires=INSTALL_REQUIRES,
    extras_require=EXTRAS_REQUIRE,
)

Если установить последнюю версию werkzeug (в1.0.0), то Flask-RESTx перестанет работать. Но это связано с ошибкой импорта и скоро будет исправлено. В настоящее время werkzeug привязан к последней версии, которая не ломает Flask-RESTx, и я обновлю данный материал, как только данная проблема будет решена.

Если вы не знакомы со структурой или работой файла setup.py, рекомендую добавить в закладки данный пример из PyPA, который имеет полную документацию с комментариями для разъяснения каждого именованного аргумента, поддерживаемого в функции setup, какие kwargs обязательны или необязательны и т.д.

Установка flask-api-tutorial

Наконец, установим приложение flask-api-tutorial в редактируемом режиме:

(flask-api-tutorial) flask-api-tutorial $ pip install -e .[dev]
# removed package install messages...
Installing collected packages: PyJWT, six, pycparser, cffi, bcrypt, itsdangerous, MarkupSafe, Jinja2, click, werkzeug, Flask, Flask-Bcrypt, python-dotenv, SQLAlchemy, Flask-SQLAlchemy, python-editor, python-dateutil, Mako, alembic, Flask-Migrate, pytz, pyrsistent, attrs, zipp, importlib-metadata, jsonschema, aniso8601, flask-restx, urllib3, certifi, chardet, idna, requests, Flask-Cors, more-itertools, py, wcwidth, pyparsing, packaging, pluggy, pytest, snowballstemmer, pydocstyle, pytest-dotenv, toml, pathspec, typed-ast, regex, appdirs, black, pytest-flask, distlib, filelock, virtualenv, tox, pyyaml, identify, cfgv, nodeenv, pre-commit, termcolor, pytest-clarity, pytest-black, mccabe, pyflakes, entrypoints, pycodestyle, flake8, pytest-flake8, flask-api-tutorial
  Running setup.py develop for flask-api-tutorial
Successfully installed Flask-1.1.1 Flask-Bcrypt-0.7.1 Flask-Cors-3.0.8 Flask-Migrate-2.5.2 Flask-SQLAlchemy-2.4.1 Jinja2-2.11.1 Mako-1.1.1 MarkupSafe-1.1.1 PyJWT-1.7.1 SQLAlchemy-1.3.13 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 cfgv-3.1.0 chardet-3.0.4 click-7.0 distlib-0.3.0 entrypoints-0.3 filelock-3.0.12 flake8-3.7.9 flask-api-tutorial flask-restx-0.1.1 identify-1.4.11 idna-2.9 importlib-metadata-1.5.0 itsdangerous-1.1.0 jsonschema-3.2.0 mccabe-0.6.1 more-itertools-8.2.0 nodeenv-1.3.5 packaging-20.1 pathspec-0.7.0 pluggy-0.13.1 pre-commit-2.1.1 py-1.8.1 pycodestyle-2.5.0 pycparser-2.19 pydocstyle-5.0.2 pyflakes-2.1.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.11.0 python-editor-1.0.4 pytz-2019.3 pyyaml-5.3 regex-2020.2.20 requests-2.23.0 six-1.14.0 snowballstemmer-2.0.0 termcolor-1.1.0 toml-0.10.0 tox-3.14.5 typed-ast-1.4.1 urllib3-1.25.8 virtualenv-20.0.7 wcwidth-0.1.8 werkzeug-0.16.1 zipp-3.0.0

Команда "pip install -e ." устанавливает приложение flask-api-tutorial в виртуальной среде в редактируемом режиме. Благодаря этому можно выполнять наши тесты с использованием рассмотренной выше компоновки папок, а также можно тестировать любые изменения, внесенные в код приложения или тестов, без необходимости повторно устанавливать приложение flask-api-tutorial.

Затем запускаем команду pre-commit install, чтобы реально добавить хуки в локальную папку .git:

(flask-api-tutorial) flask-api-tutorial $ pre-commit install
pre-commit installed at .git/hooks/pre-commit

Пакет flask_api_tutorial.util

Пакет flask_api_tutorial.util содержит универсальные утилитные классы и функции, которые мы будем использовать в данном проекте. Они не имеют отношения к основным темам данного руководства, поэтому давайте разберемся с ними перед началом работы над реальным проектом.

Класс Result

В прошлых постах я продемонстрировал и объяснил преимущества добавления принципов из функционального программирования на примере полезного класса Result. Мы часто будем использовать данный класс, поэтому, пожалуйста, прочтите пост по ссылке. Когда закончите с ним, создайте в папке src/flask_api_tutorial/util новый файл с именем result.py и добавьте следующее содержимое:

"""Класс Result отображает результат операции."""


class Result:
    """Отобразить результат операции."""

    def __init__(self, success, value, error):
        """Отобразить результат операции."""
        self.success = success
        self.error = error
        self.value = value

    def __str__(self):
        """Неформальное строковое представление результата."""
        if self.success:
            return "[Success]"
        else:
            return f"[Failure] {self.error}"

    def __repr__(self):
        """Официальное строковое представление результата."""
        if self.success:
            return f"<Result success={self.success}>"
        else:
            return f'<Result success={self.success}, message="{self.error}">'

    @property
    def failure(self):
        """Флаг для отображения, выполнилась ли операция неудачно."""
        return not self.success

    def on_success(self, func, *args, **kwargs):
        """Передать результат успешной операции (если есть) в следующую функцию."""
        if self.failure:
            return self
        if self.value:
            return func(self.value, *args, **kwargs)
        return func(*args, **kwargs)

    def on_failure(self, func, *args, **kwargs):
        """Передать сообщение об ошибке из неудачной операции в следующую функцию."""
        if self.success:
            return self.value if self.value else None
        if self.error:
            return func(self.error, *args, **kwargs)
        return func(*args, **kwargs)

    def on_both(self, func, *args, **kwargs):
        """Передать результат (успешный/неудачный) в следующую функцию."""
        if self.value:
            return func(self.value, *args, **kwargs)
        return func(*args, **kwargs)

    @staticmethod
    def Fail(error_message):
        """Создать объект Result object для неудачной операции."""
        return Result(False, value=None, error=error_message)

    @staticmethod
    def Ok(value=None):
        """Создать объект Result для успешной операции."""
        return Result(True, value=value, error=None)

    @staticmethod
    def Combine(results):
        """Вернуть объект Result на основе результата списка объектов Results."""
        if all(result.success for result in results):
            return Result.Ok()
        errors = [result.error for result in results if result.failure]
        return Result.Fail("\n".join(errors))

Модуль datetime_util

Если вы когда-нибудь программировали на Python, вероятность 100 %, что вы сталкивались с неприятной проблемой с объектами datetime, timezone и (или) timedelta. Модуль datetime_util содержит вспомогательные функции для преобразования объектов datetime из наивных объектов в объекты, учитывающие часовой пояс, для форматирования объектов datetime и timedelta в строки и namedtuple с именем timespan, который отражает разницу между двумя значениями datetime, но дает больше данных, чем набор атрибутов, предоставляемых классом timedelta.

Создаем в папке src/flask_api_tutorial/util новый файл с именем datetime_util.py и добавляем следующее содержимое:

"""Вспомогательные функции для объектов datetime, timezone и timedelta."""
import time
from collections import namedtuple
from datetime import datetime, timedelta, timezone


DT_AWARE = "%m/%d/%y %I:%M:%S %p %Z"
DT_NAIVE = "%m/%d/%y %I:%M:%S %p"
DATE_MONTH_NAME = "%b %d %Y"
ONE_DAY_IN_SECONDS = 86400

timespan = namedtuple(
    "timespan",
    [
        "days",
        "hours",
        "minutes",
        "seconds",
        "milliseconds",
        "microseconds",
        "total_seconds",
        "total_milliseconds",
        "total_microseconds",
    ],
)


def utc_now():
    """Текущая дата и текущее время по всемирному времени с нормализацией значения микросекунды до нуля."""
    return datetime.now(timezone.utc).replace(microsecond=0)


def localized_dt_string(dt, use_tz=None):
    """Преобразовать значение datetime в строку, локализованную по указанному часовому поясу."""
    if not dt.tzinfo and not use_tz:
        return dt.strftime(DT_NAIVE)
    if not dt.tzinfo:
        return dt.replace(tzinfo=use_tz).strftime(DT_AWARE)
    return dt.astimezone(use_tz).strftime(DT_AWARE) if use_tz else dt.strftime(DT_AWARE)


def get_local_utcoffset():
    """Получить смещение всемирного времени из локальной системы и возвратить в виде объекта timezone."""
    utc_offset = timedelta(seconds=time.localtime().tm_gmtoff)
    return timezone(offset=utc_offset)


def make_tzaware(dt, use_tz=None, localize=True):
    """Изменить наивный объект datetime, чтобы он знал часовой пояс."""
    if not use_tz:
        use_tz = get_local_utcoffset()
    return dt.astimezone(use_tz) if localize else dt.replace(tzinfo=use_tz)


def dtaware_fromtimestamp(timestamp, use_tz=None):
    """Объект datetime должен знать часовой пояс из временной метки UNIX."""
    timestamp_naive = datetime.fromtimestamp(timestamp)
    timestamp_aware = timestamp_naive.replace(tzinfo=get_local_utcoffset())
    return timestamp_aware.astimezone(use_tz) if use_tz else timestamp_aware


def remaining_fromtimestamp(timestamp):
    """Вычислить время, оставшееся с данного момента до значения временной метки UNIX."""
    now = datetime.now(timezone.utc)
    dt_aware = dtaware_fromtimestamp(timestamp, use_tz=timezone.utc)
    if dt_aware < now:
        return timespan(0, 0, 0, 0, 0, 0, 0, 0, 0)
    return get_timespan(dt_aware - now)


def format_timespan_digits(ts):
    """Отформатировать namedtuple с временным интервалом в виде строки, похожей на цифровой дисплей."""
    if ts.days:
        day_or_days = "days" if ts.days > 1 else "day"
        return (
            f"{ts.days} {day_or_days}, "
            f"{ts.hours:02d}:{ts.minutes:02d}:{ts.seconds:02d}"
        )
    if ts.seconds:
        return f"{ts.hours:02d}:{ts.minutes:02d}:{ts.seconds:02d}"
    return f"00:00:00.{ts.total_microseconds}"


def format_timedelta_digits(td):
    """Отформатировать объект timedelta в виде строки, похожей на цифровой дисплей."""
    return format_timespan_digits(get_timespan(td))


def format_timespan_str(ts):
    """Отформатировать namedtuple с временным интервалом в виде читаемой строки."""
    if ts.days:
        day_or_days = "days" if ts.days > 1 else "day"
        return (
            f"{ts.days} {day_or_days} "
            f"{ts.hours:.0f} hours {ts.minutes:.0f} minutes {ts.seconds} seconds"
        )
    if ts.hours:
        return f"{ts.hours:.0f} hours {ts.minutes:.0f} minutes {ts.seconds} seconds"
    if ts.minutes:
        return f"{ts.minutes:.0f} minutes {ts.seconds} seconds"
    if ts.seconds:
        return f"{ts.seconds} seconds {ts.milliseconds:.0f} milliseconds"
    return f"{ts.total_microseconds} mircoseconds"


def format_timedelta_str(td):
    """Отформатировать объект timedelta в виде читаемой строки."""
    return format_timespan_str(get_timespan(td))


def get_timespan(td):
    """Преобразовать объект timedelta в namedtuple с временным интервалом."""
    (milliseconds, microseconds) = divmod(td.microseconds, 1000)
    (minutes, seconds) = divmod(td.seconds, 60)
    (hours, minutes) = divmod(minutes, 60)
    total_seconds = td.seconds + (td.days * ONE_DAY_IN_SECONDS)
    return timespan(
        td.days,
        hours,
        minutes,
        seconds,
        milliseconds,
        microseconds,
        total_seconds,
        (total_seconds * 1000 + milliseconds),
        (total_seconds * 1000 * 1000 + milliseconds * 1000 + microseconds),
    )

Конфигурация среды

Затем создаем файл с именем config.py в папке src/flask_api_tutorial и добавляем следующее содержимое. Сохраняем файл:

"""Настройки конфигурации для сред разработки, тестирования и продуктива."""
import os
from pathlib import Path


HERE = Path(__file__).parent
SQLITE_DEV = "sqlite:///" + str(HERE / "flask_api_tutorial_dev.db")
SQLITE_TEST = "sqlite:///" + str(HERE / "flask_api_tutorial_test.db")
SQLITE_PROD = "sqlite:///" + str(HERE / "flask_api_tutorial_prod.db")


class Config:
    """Базовая конфигурация."""

    SECRET_KEY = os.getenv("SECRET_KEY", "open sesame")
    BCRYPT_LOG_ROUNDS = 4
    TOKEN_EXPIRE_HOURS = 0
    TOKEN_EXPIRE_MINUTES = 0
    SQLALCHEMY_TRACK_MODIFICATIONS = False
    PRESERVE_CONTEXT_ON_EXCEPTION = False
    SWAGGER_UI_DOC_EXPANSION = "list"
    RESTX_MASK_SWAGGER = False
    JSON_SORT_KEYS = False


class TestingConfig(Config):
    """Тестовая конфигурация."""

    TESTING = True
    SQLALCHEMY_DATABASE_URI = SQLITE_TEST


class DevelopmentConfig(Config):
    """Конфигурация для разработки."""

    TOKEN_EXPIRE_MINUTES = 15
    SQLALCHEMY_DATABASE_URI = os.getenv("DATABASE_URL", SQLITE_DEV)


class ProductionConfig(Config):
    """Конфигурация для продуктива."""

    TOKEN_EXPIRE_HOURS = 1
    BCRYPT_LOG_ROUNDS = 13
    SQLALCHEMY_DATABASE_URI = os.getenv("DATABASE_URL", SQLITE_PROD)
    PRESERVE_CONTEXT_ON_EXCEPTION = True


ENV_CONFIG_DICT = dict(
    development=DevelopmentConfig, testing=TestingConfig, production=ProductionConfig
)


def get_config(config_name):
    """Извлечь параметры конфигурации среды."""
    return ENV_CONFIG_DICT.get(config_name, ProductionConfig)

Следует отметить несколько важных моментов по поводу класса Config:

Строка 15: В базовом классе Config мы храним значение переменной среды SECRET_KEY. Поскольку оно всегда должно иметь какое-то значение, используется значение по умолчанию "open sesame", если переменная SECRET_KEY не установлена. Данное и все остальные значения, установленные в базовом классе, доступны в подклассах Config (TestingConfig, DevelopmentConfig, ProductionConfig).

Строки 17-18, 35, 42: Именно здесь мы начнем использовать свои подклассы для создания уникальных конфигураций в каждой среде. Время до истечения срока действия токена определяется данными двумя значениями:

Токен истекает через: TOKEN_EXPIRE_HOURS + TOKEN_EXPIRE_MINUTES + 5 секунд (жестко запрограммированное добавление 5 секунд позволяет писать тестовые сценарии, в которых срок действия токена доступа уже истек).

TestingConfig: 5 секунд

DevelopmentConfig: 15 минут, 5 секунд

ProductionConfig: 1 час 5 секунд

Строки 29, 36, 44: Значение для SQLALCHEMY_DATABASE_URI устанавливается по-разному для каждой среды. По умолчанию для каждой среды будут использоваться разные файлы базы данных SQLite. Однако, если добавить переменную среды с именем DATABASE_URL в файл .env, который содержит URL-адрес экземпляра базы данных (например, PostgreSQL, MSSQL и т.д.), данное значение будет использоваться для среды разработки или продуктива, исходя из значения FLASK_ENV.

Строка 53: Функция get_config извлекает параметры конфигурации для каждой среды. Ее будет использовать метод create_app, создающий экземпляр приложения Flask.

python-dotenv

Чтобы наши классы Config работали как нужно, необходимо установить переменные среды, определенные в .env. Вместо того, чтобы устанавливать значение для каждой переменной среды в командной строке каждый раз, когда мы открываем новый терминал, мы будем использовать пакет python-dotenv для автоматической установки значений. python-dotenv установлен из setup.py и не требует никакой настройки после установки.

Пока установлен python-dotenv, при запуске команды flask будут определяться все переменные среды из .env. Благодаря этому метод os.getenv может извлекать значения, определенные в .env, и использовать их в нашем приложении Flask.

Никогда не делайте коммиты с файлом .env в Git-репозитории своего проекта. Если сделать это, SECRET_KEY попадет в общий доступ, что позволит любому пользователю назначать токены авторизации для API.

Шаблон разработки Application Factory (Фабрика приложения)

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

"""Инициализация приложения 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))

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

Мы используем Шаблон Application Factory для создания экземпляров своего приложения. Благодаря этому упрощается создание разных версий нашего приложения с разными настройками; просто указываем нужный вид среды в качестве параметра config_name для функции create_app. Данное значение извлекает параметры конфигурации с помощью функции get_config, которую мы создали в config.py.

После создания приложения Flask и применения настроек конфигурации мы инициализируем объекты для расширения (cors, db, migrate, bcrypt), вызывая метод init_app в каждом объекте и передавая данному методу вновь созданное приложение Flask. Если сделать это, в объекте для расширения не сохраняется специфичное состояние приложения, поэтому один объект для расширения можно использовать для нескольких приложений.

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

С помощью показанной инициализации расширения Flask-CORS включается поддержка CORS для всех доменов и всех источников всех маршрутов (routes).

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

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

"""Модульные тесты для настроек конфигурации среды."""
import os

from flask_api_tutorial import create_app
from flask_api_tutorial.config import SQLITE_DEV, SQLITE_PROD, SQLITE_TEST


def test_config_development():
    app = create_app("development")
    assert app.config["SECRET_KEY"] != "open sesame"
    assert not app.config["TESTING"]
    assert app.config["SQLALCHEMY_DATABASE_URI"] == os.getenv("DATABASE_URL", SQLITE_DEV)
    assert app.config["TOKEN_EXPIRE_HOURS"] == 0
    assert app.config["TOKEN_EXPIRE_MINUTES"] == 15


def test_config_testing():
    app = create_app("testing")
    assert app.config["SECRET_KEY"] != "open sesame"
    assert app.config["TESTING"]
    assert app.config["SQLALCHEMY_DATABASE_URI"] == SQLITE_TEST
    assert app.config["TOKEN_EXPIRE_HOURS"] == 0
    assert app.config["TOKEN_EXPIRE_MINUTES"] == 0


def test_config_production():
    app = create_app("production")
    assert app.config["SECRET_KEY"] != "open sesame"
    assert not app.config["TESTING"]
    assert app.config["SQLALCHEMY_DATABASE_URI"] == os.getenv(
        "DATABASE_URL", SQLITE_PROD
    )
    assert app.config["TOKEN_EXPIRE_HOURS"] == 1
    assert app.config["TOKEN_EXPIRE_MINUTES"] == 0

Чтобы программа запуска pytest нашла тесты, все тестовые классы и методы тестовых сценариев должны начинаться со слова test (или Test).

В первой строке каждого тестового сценария вызывается функция create_app для создания объекта приложения flask с нужными настройками конфигурации. Мы передаем имя среды в функцию create_app, которая извлекает нужные настройки конфигурации из функции get_config.

Для каждой конфигурации мы проверяем, что значение SECRET_KEY не равно значению по умолчанию; это подтверждает, что значение из файла .env извлечено успешно. Также мы проверяем, что каждый URL-адрес базы данных задан правильно и что настройки TOKEN_EXPIRE_HOURS и TOKEN_EXPIRE_MINUTES правильные для каждой среды.

Если вы используете другую базу данных для любой среды и извлекли URL-адрес из файла .env, убедитесь в том, что вы обновили тестовый сценарий, чтобы проверять правильность извлечения данного значения.

Мы можем запустить данные тесты (и наши инструменты статического анализа) командой tox. При таком подходе есть дополнительна польза: проверяется, что файл setup.py правильно устанавливает наше приложение:

(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.11.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='3249524107'
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 27 items

setup.py::FLAKE8 PASSED                                                                                          [  3%]
setup.py::BLACK PASSED                                                                                           [  7%]
src/flask_api_tutorial/__init__.py::FLAKE8 PASSED                                                                [ 11%]
src/flask_api_tutorial/__init__.py::BLACK PASSED                                                                 [ 14%]
src/flask_api_tutorial/config.py::FLAKE8 PASSED                                                                  [ 18%]
src/flask_api_tutorial/config.py::BLACK PASSED                                                                   [ 22%]
src/flask_api_tutorial/api/__init__.py::FLAKE8 PASSED                                                            [ 25%]
src/flask_api_tutorial/api/__init__.py::BLACK PASSED                                                             [ 29%]
src/flask_api_tutorial/api/auth/__init__.py::FLAKE8 PASSED                                                       [ 33%]
src/flask_api_tutorial/api/auth/__init__.py::BLACK PASSED                                                        [ 37%]
src/flask_api_tutorial/api/widgets/__init__.py::FLAKE8 PASSED                                                    [ 40%]
src/flask_api_tutorial/api/widgets/__init__.py::BLACK PASSED                                                     [ 44%]
src/flask_api_tutorial/models/__init__.py::FLAKE8 PASSED                                                         [ 48%]
src/flask_api_tutorial/models/__init__.py::BLACK PASSED                                                          [ 51%]
src/flask_api_tutorial/util/__init__.py::FLAKE8 PASSED                                                           [ 55%]
src/flask_api_tutorial/util/__init__.py::BLACK PASSED                                                            [ 59%]
src/flask_api_tutorial/util/datetime_util.py::FLAKE8 PASSED                                                      [ 62%]
src/flask_api_tutorial/util/datetime_util.py::BLACK PASSED                                                       [ 66%]
src/flask_api_tutorial/util/result.py::FLAKE8 PASSED                                                             [ 70%]
src/flask_api_tutorial/util/result.py::BLACK PASSED                                                              [ 74%]
tests/__init__.py::FLAKE8 PASSED                                                                                 [ 77%]
tests/__init__.py::BLACK PASSED                                                                                  [ 81%]
tests/test_config.py::FLAKE8 PASSED                                                                              [ 85%]
tests/test_config.py::BLACK PASSED                                                                               [ 88%]
tests/test_config.py::test_config_development PASSED                                                             [ 92%]
tests/test_config.py::test_config_testing PASSED                                                                 [ 96%]
tests/test_config.py::test_config_production PASSED                                                              [100%]

================================================== 27 passed in 7.30s ==================================================
_______________________________________________________ summary ________________________________________________________
  py37: commands succeeded
  congratulations :)

Интерфейс командной строки Flask/точка входа в приложение

Одна из многих красивых особенностей Flask в том, что он изначально предусматривает встроенный интерфейс командной строки (CLI), работающий на основе click. Чтобы использовать интерфейс командной строки, у Flask должна быть возможность найти экземпляр приложения; это можно сделать через переменную среды FLASK_APP. В параметре FLASK_APP необходимо установить путь к файлу или путь импорта модуля, содержащего приложение Flask (дополнительную информацию можно прочитать здесь).

Проверьте, что вы активировали свою виртуальную среду, иначе не сможете использовать интерфейс командной строки Flask.

Возможно, вы помните, что FLASK_APP было среди значений, которые мы определили в своем файле .env (FLASK_APP = run.py). Оно указывает Flask искать в run.py объект с именем app (или метод по шаблону factory с именем create_app). Пока данный файл не существует. Если попытаться запустить интерфейс командной строки Flask с помощью команды flask, выдается исключение:

(flask-api-tutorial) flask-api-tutorial $ flask
Traceback (most recent call last):
  File "/Users/aaronluna/Projects/flask-api-tutorial/venv/lib/python3.7/site-packages/flask/cli.py", line 240, in locate_app
    __import__(module_name)
ModuleNotFoundError: No module named 'run'

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "/Users/aaronluna/Projects/flask-api-tutorial/venv/lib/python3.7/site-packages/flask/cli.py", line 556, in list_commands
    rv.update(info.load_app().cli.list_commands(ctx))
  File "/Users/aaronluna/Projects/flask-api-tutorial/venv/lib/python3.7/site-packages/flask/cli.py", line 388, in load_app
    app = locate_app(self, import_name, name)
  File "/Users/aaronluna/Projects/flask-api-tutorial/venv/lib/python3.7/site-packages/flask/cli.py", line 250, in locate_app
    raise NoAppException('Could not import "{name}".'.format(name=module_name))
flask.cli.NoAppException: Could not import "run".
Usage: flask [OPTIONS] COMMAND [ARGS]...

  A general utility script for Flask applications.

  Provides commands from Flask, extensions, and the application. Loads the
  application defined in the FLASK_APP environment variable, or from a
  wsgi.py file. Setting the FLASK_ENV environment variable to 'development'
  will enable debug mode.

    $ export FLASK_APP=hello.py
    $ export FLASK_ENV=development
    $ flask run

Options:
  --version  Show the flask version
  --help     Show this message and exit.

Commands:
  db      Perform database migrations.
  routes  Show the routes for the app.
  run     Run a development server.
  shell   Run a shell in the app context.

Поскольку мы установили python-dotenv, переменные среды, предусмотренные в .env, считываются из файла при каждом выполнении команды flask (доступ к ним можно получить через метод os.getenv). Без этого нам бы пришлось устанавливать значение FLASK_APP вручную при каждом открытии нового окна терминала.

Создаем новый файл с именем run.py в корневой папке проекта и добавим следующее содержимое:

"""Интерфейс командной строки Flask/Точка входа в приложение."""
import os

from flask_api_tutorial import create_app, db

app = create_app(os.getenv("FLASK_ENV", "development"))


@app.shell_context_processor
def shell():
    return {"db": db}

Обратите внимание на следующую информацию по run.py (также известном как точка входа в приложение):

Строка 6: Это объект приложения Flask, который должен существовать в модуле run, чтобы команда flask выполнялась без выдачи исключения.

Строка 9: Декоратор @app.shell_context_processor запускает выполнение декорированного метода при выполнении команды flask shell.

Строка 11: Команда flask shell автоматически импортирует объекты, определенные в словаре, который возвращается из функции shell.

Метод shell в файле run.py имеет декоратор @app.shell_context_processor. Данный метод выполняется при запуске flask shell. Согласно документации flask --help данная команда «запускает оболочку в контексте приложения». Если вы не уверены в смысле данной фразы, посмотрите на примеры ниже:

(flask-api-tutorial) flask-api-tutorial $ python
Python 3.7.6 (default, Jan 19 2020, 06:08:58)
[Clang 11.0.0 (clang-1100.0.33.8)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> app
Traceback (most recent call last):
  File "", line 1, in 
NameError: name 'app' is not defined
>>> db
Traceback (most recent call last):
  File "", line 1, in 
NameError: name 'db' is not defined
>>> from run import app
>>> from flask_api_tutorial import db
>>> app
<Flask 'app'>
>>> db
<SQLAlchemy engine=sqlite:////Users/aaronluna/Projects/flask_api_tutorial/flask_api_tutorial_dev.db>
>>> exit()
(flask-api-tutorial) flask-api-tutorial $ flask shell
Python 3.7.6 (default, Jan 19 2020, 06:08:58)
[Clang 11.0.0 (clang-1100.0.33.8)] on darwin
App: app [development]
Instance: /Users/aaronluna/Projects/flask_api_tutorial/instance
>>> app
<Flask 'app'>
>>> db
<SQLAlchemy engine=sqlite:////Users/aaronluna/Projects/flask_api_tutorial/flask_api_tutorial_dev.db>
>>> exit()

В обычном интерпретаторе Python объекты app и db не распознаются, если они не импортированы явно. Но, когда мы запускаем интерпретатор с помощью flask shell, объект app импортируется автоматически. Это хорошо, но flask shell становится действительно полезной командой благодаря возможности импортировать словарь объектов, который будут автоматически импортироваться в интерпретатор Python. Мы настраиваем данный словарь как возвращаемое значение функции shell_context_processor:

@app.shell_context_processor
def shell():
    return {"db": db}

В Части 2, когда мы сделаем классы моделей для каждой таблицы базы данных, мы добавим модели в данный словарь, чтобы они были доступны нам в контексте оболочки без импорта вручную. Функция shell_context_processor должна возвращать словарь, а не список. Это позволяет нам контролировать используемые в оболочке имена, так как ключ словаря для каждого объекта будет использоваться в качестве имени.

Давайте убедимся, что ошибка, которую мы видели раньше, исправлена; запустим flask:

(flask-api-tutorial) flask-api-tutorial $ flask
Usage: flask [OPTIONS] COMMAND [ARGS]...

  A general utility script for Flask applications.

  Provides commands from Flask, extensions, and the application. Loads the
  application defined in the FLASK_APP environment variable, or from a
  wsgi.py file. Setting the FLASK_ENV environment variable to 'development'
  will enable debug mode.

    $ export FLASK_APP=hello.py
    $ export FLASK_ENV=development
    $ flask run

Options:
  --version  Show the flask version
  --help     Show this message and exit.

Commands:
  db      Perform database migrations.
  routes  Show the routes for the app.
  run     Run a development server.
  shell   Run a shell in the app context.

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

Большая часть работы, проделанной в данном разделе, не связана ни с какими конкретными требованиями проекта, но я думаю, что мы можем получить хотя бы частичную оценку об исполнении одного пункта (настройки ProductionConfig определяют срок действия токена в один час и будут использоваться при создании токенов JSON Web Token). Пункт «Срок действия токенов JSON Web Token должен истекать через 1 час (в продуктиве)» был помечен как частично завершенный ( ):

Управление пользователями/проверка подлинности по токенам 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 должен содержать сообщения об ошибках с указанием имен полей, в которых произошел сбой валидации.