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

Оглавление

Вся функциональность, которую я до сих пор создавал для этого приложения, предназначена для одного конкретного типа клиентов: веб-браузера. Но как насчет других типов клиентов? Если бы я хотел создать, например, приложение для Android или iOS, у меня есть два основных способа сделать это. Самым простым решением было бы создать простое приложение, используя только компонент веб-просмотра, который заполняет весь экран, на который загружается веб-сайт микроблога, но это дало бы мало преимуществ по сравнению с открытием приложения в веб-браузере устройства. Лучшим решением (хотя и гораздо более трудоемким) было бы создание собственного приложения, но как это приложение может взаимодействовать с сервером, который возвращает только HTML-страницы?

Это проблемная область, в которой могут помочь интерфейсы прикладного программирования (или API). API - это набор HTTP-маршрутов, которые разработаны как низкоуровневые точки входа в приложение. Вместо определения маршрутов и функций просмотра, которые возвращают HTML для использования веб-браузерами, API позволяют клиенту напрямую работать с ресурсами приложения, оставляя решение о том, как представить информацию пользователю, полностью за клиентом. Например, API в Microblog может предоставлять клиенту информацию о пользователе и записи в блоге, а также может позволять пользователю редактировать существующую запись в блоге, но только на уровне данных, не смешивая эту логику с HTML.

Если вы изучите все маршруты, определенные в настоящее время в приложении, вы заметите, что есть несколько, которые могли бы соответствовать определению API, которое я использовал выше. Вы их нашли? Я говорю о нескольких маршрутах, которые возвращают JSON, таких как маршрут /translate, определенный в главе 14. Это маршрут, который принимает текст, исходный и конечный языки, все данные в формате JSON в запросе POST. Ответом на этот запрос является перевод этого текста, также в формате JSON. Сервер возвращает только запрошенную информацию, оставляя на клиенте ответственность за предоставление этой информации пользователю.

Хотя маршруты JSON в приложении "чувствуют" себя как API, они были разработаны для поддержки веб-приложения, запущенного в браузере. Учтите, что если бы приложение для смартфона захотело использовать эти маршруты, оно не смогло бы этого сделать, потому что для них требуется авторизованный пользователь, а вход в систему может осуществляться только через HTML-форму. В этой главе я собираюсь показать, как создавать API, которые не зависят от веб-браузера и не делают предположений о том, какой клиент подключается к ним.

Ссылки на GitHub для этой главы: BrowseZipDiff.

REST как основа проектирования API

Некоторые люди могут быть категорически не согласны с моим заявлением выше о том, что /translate и другие маршруты JSON являются маршрутами API. Другие могут согласиться с оговоркой, что они считают их плохо разработанными API. Итак, каковы характеристики хорошо разработанного API и почему маршруты JSON не относятся к этой категории?

Возможно, вы слышали термин REST API. REST, что расшифровывается как Representational State Transfer, представляет собой архитектуру, предложенную доктором Роем Филдингом в его докторской диссертации. В своей работе доктор Филдинг представляет шесть определяющих характеристик REST в довольно абстрактной и обобщенной форме.

Для REST нет авторитетной спецификации, кроме диссертации доктора Филдинга, и это оставляет читателю множество деталей для интерпретации. Тема того, соответствует ли данный API REST или нет, часто является источником жарких споров между "пуристами" REST, которые считают, что REST API должен соблюдать все шесть характеристик и делать это очень специфичным образом, и остальными "прагматиками", которые воспринимают идеи, представленные доктором Филдингом в его диссертации, как руководящие принципы или рекомендации. Сам доктор Филдинг поддерживает лагерь пуристов и предоставил некоторое дополнительное представление о своем видении в сообщениях в блоге и онлайн-комментариях.

Подавляющее большинство реализованных в настоящее время API придерживаются "прагматичной" реализации REST. Сюда входит большинство API от "крупных игроков", таких как Facebook, GitHub, Twitter и т.д. Существует очень мало общедоступных API, которые единодушно считаются чистыми REST, потому что в большинстве API отсутствуют определенные детали реализации, которые пуристы считают обязательными. Несмотря на строгие взгляды доктора Филдинга и других пуристов REST на то, что является REST API, а что нет, в индустрии программного обеспечения принято ссылаться на REST в прагматическом смысле.

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

Клиент-сервер

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

Многоуровневая система

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

Это важная особенность REST, поскольку возможность добавления промежуточных узлов позволяет архитекторам приложений проектировать большие и сложные сети, способные удовлетворять большой объем запросов за счет использования балансировщиков нагрузки, кэшей, прокси-серверов и т.д.

Кэш

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

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

Код по запросу

Это необязательное требование, которое гласит, что сервер может предоставлять исполняемый код в ответах клиенту. Поскольку этот принцип требует соглашения между сервером и клиентом о том, какой исполняемый код клиент может запускать, это редко используется в API. Можно подумать, что сервер может возвращать код JavaScript для выполнения клиентами веб-браузера, но REST не предназначен специально для клиентов веб-браузера. Выполнение JavaScript, например, может привести к усложнению, если клиентом является устройство iOS или Android.

Без состояния

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

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

Если вы снова рассмотрите маршрут /translate, рассмотренный во введении этой главы, вы поймете, что его нельзя считать RESTful, потому что функция просмотра, связанная с этим маршрутом, полагается на декоратор @login_required из Flask-Login , который, в свою очередь, сохраняет состояние входа пользователя в систему в сеансе пользователя Flask.

Единый интерфейс

Последний, самый важный, наиболее обсуждаемый и наиболее расплывчато документированный принцип REST - это единый интерфейс. Доктор Филдинг перечисляет четыре отличительных аспекта унифицированного интерфейса REST: уникальные идентификаторы ресурсов, представления ресурсов, самоописывающие сообщения и гипермедиа.

Уникальные идентификаторы ресурсов достигаются путем присвоения уникального URL-адреса каждому ресурсу. Например, URL-адрес, связанный с данным пользователем, может быть /api/users/<user-id>, где <user-id> - идентификатор, присвоенный пользователю в первичном ключе таблицы базы данных. Это достаточно хорошо реализовано большинством API.

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

Самоописывающие сообщения означают, что запросы и ответы, которыми обмениваются клиенты и сервер, должны содержать всю информацию, которая необходима другой стороне. В качестве типичного примера метод HTTP-запроса используется для указания, какую операцию клиент хочет, чтобы сервер выполнил. Запрос GET указывает, что клиент хочет получить информацию о ресурсе, запрос POST указывает, что клиент хочет создать новый ресурс, запросы PUT или PATCH определяют модификации существующих ресурсов, и запрос DELETE указывает на удаление ресурса. Целевой ресурс указывается в качестве URL-адреса запроса, а дополнительная информация предоставляется в заголовках HTTP, в части URL-адреса со строкой запроса или в теле запроса.

Требование к гипермедиа является наиболее спорным из всего набора, и его реализуют немногие API, а те API, которые его реализуют, редко делают это таким образом, чтобы удовлетворить пуристов REST. Поскольку все ресурсы в приложении взаимосвязаны, этот принцип требует, чтобы эти связи были включены в представления ресурсов, чтобы клиенты могли открывать новые ресурсы, просматривая связи, примерно так же, как вы открываете новые страницы в веб-приложении, нажимая на ссылки, которые переводят вас с одной страницы на следующую. Идея заключается в том, что клиент может войти в API без каких-либо предварительных знаний о содержащихся в нем ресурсах и узнать о них, просто перейдя по ссылкам гипермедиа. Одним из аспектов, усложняющих реализацию этого требования, является то, что в отличие от HTML и XML, формат JSON, который обычно используется для представления ресурсов в API, не определяет стандартный способ включения ссылок, поэтому вы вынуждены использовать пользовательскую структуру или одно из предлагаемых расширений JSON, которые пытаются устранить этот пробел, например, JSON-APIHALJSON-LD или аналогичные.

Реализация схемы API

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

Чтобы все было организовано и в соответствии со структурой, которую я описал в главе 15, я собираюсь создать новую схему, которая будет содержать все маршруты API. Итак, давайте начнем с создания каталога, в котором будет находиться этот проект:

(venv) $ mkdir app/api

Файл __init__.py создает объект Blueprint, аналогичный другим Blueprint в приложении:

app/api/__init__.py: Конструктор Blueprint API.

from flask import Blueprint

bp = Blueprint('api', __name__)

from app.api import users, errors, tokens

Вы, наверное, помните, что иногда необходимо переместить импорт в самый низ, чтобы избежать ошибок циклических зависимостей. Именно по этой причине модули app/api/users.py, app/api/errors.py и app/api/tokens.py (которые мне еще предстоит написать) импортируются после создания Blueprint.

Основная часть API будет храниться в модуле app/api/users.py. В следующей таблице приведены маршруты, которые я собираюсь реализовать.:

Сейчас я собираюсь создать скелет модуля с заполнителями для всех этих маршрутов:

app/api/users.py: Заполнители ресурсов пользовательского API.

from app.api import bp

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

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

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

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

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

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

В модуле app/api/errors.py будет определено несколько вспомогательных функций, которые обрабатывают ответы на ошибки. Но сейчас я также собираюсь использовать заполнитель, который напишу позже.:

app/api/errors.py: Заполнитель для обработки ошибок.

def bad_request():
    pass

В модуле app/api/tokens.py будет определена подсистема аутентификации. Это предоставит альтернативный способ входа в систему клиентам, которые не являются веб-браузерами. Сейчас я собираюсь написать заполнитель и для этого модуля:

app/api/tokens.py: Заполнитель для обработки токенов.

def get_token():
    pass

def revoke_token():
    pass

Новый Blueprint API необходимо зарегистрировать в функции фабрики приложений:

app/__init__.py: Регистрируем Blueprint API в приложении.

# ...

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

    # ...

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

    # ...

Представление пользователей в виде объектов JSON

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

{
    "id": 123,
    "username": "susan",
    "password": "my-password",
    "email": "susan@example.com",
    "last_seen": "2021-06-20T15:04:27+00:00",
    "about_me": "Hello, my name is Susan!",
    "post_count": 7,
    "follower_count": 35,
    "following_count": 21,
    "_links": {
        "self": "/api/users/123",
        "followers": "/api/users/123/followers",
        "following": "/api/users/123/following",
        "avatar": "https://www.gravatar.com/avatar/..."
    }
}

Многие поля берутся непосредственно из модели пользователя в базе данных. Поле password особенное в том смысле, что оно будет использоваться только при регистрации нового пользователя. Как вы помните из главы 5, пароли пользователей не хранятся в базе данных, есть только хэш, поэтому пароли никогда не возвращаются. Поле email также обрабатывается особым образом, потому что я не хочу раскрывать адреса электронной почты пользователей. Поле email будет возвращаться только тогда, когда пользователи запрашивают свою собственную запись, но не тогда, когда они получают записи о других пользователях. Поля post_count, follower_count и following_count являются "виртуальными" полями, которые не существуют как поля в базе данных, но предоставляются клиенту для удобства. Это отличный пример, демонстрирующий, что представление ресурса не обязательно должно соответствовать тому, как фактически ресурс определен на сервере.

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

Одна приятная особенность формата JSON заключается в том, что он всегда преобразуется в представление в виде словаря или списка Python. Пакет json из стандартной библиотеки Python обеспечивает преобразование структур данных Python в JSON и из него. Итак, чтобы сгенерировать эти представления, я собираюсь добавить в модель User метод to_dict(), который возвращает словарь Python:

app/models.py: От модели пользователя к представлению.

from flask import url_for
# ...

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

    def posts_count(self):
        query = sa.select(sa.func.count()).select_from(
            self.posts.select().subquery())
        return db.session.scalar(query)

    def to_dict(self, include_email=False):
        data = {
            'id': self.id,
            'username': self.username,
            'last_seen': self.last_seen.replace(
                tzinfo=timezone.utc).isoformat() if self.last_seen else None,
            'about_me': self.about_me,
            'post_count': self.posts_count(),
            'follower_count': self.followers_count(),
            'following_count': self.following_count(),
            '_links': {
                'self': url_for('api.get_user', id=self.id),
                'followers': url_for('api.get_followers', id=self.id),
                'following': url_for('api.get_following', id=self.id),
                'avatar': self.avatar(128)
            }
        }
        if include_email:
            data['email'] = self.email
        return data

Этот метод должен быть в основном понятен сам по себе. Словарь с представлением пользователя, на котором я остановился, просто генерируется и возвращается. Для вычисления количества постов, подписчиков и подписок я использую вспомогательные методы, добавляя один для количества постов, который мне никогда раньше не приходилось использовать. Как я упоминал выше, поле email нуждается в особой обработке, потому что я хочу включать адрес электронной почты только тогда, когда пользователи запрашивают свои собственные данные. Итак, я использую флаг include_email, чтобы определить, будет ли это поле включено в представление или нет.

Обратите внимание, как генерируется поле last_seen. Для полей даты и времени я собираюсь использовать формат ISO 8601, который datetime Python может генерировать с помощью метода isoformat(). Но поскольку SQLAlchemy использует объекты datetime, которые соответствуют UTC, в их состоянии не записан часовой пояс, мне нужно сначала установить часовой пояс, чтобы убедиться, что он включен в строку ISO 8601.

Наконец, посмотрите, как я реализовал гипермедийные ссылки. Для трех ссылок, которые указывают на другие маршруты приложений, я использую url_for() для генерации URL-адресов (которые в настоящее время указывают на функции-заполнители представления, которые я определил в app/api/users.py). Ссылка на аватар особенная, потому что это URL-адрес Gravatar, внешний по отношению к приложению. Для этой ссылки я использую тот же метод avatar(), который я использовал для отображения аватаров на веб-страницах.

Метод to_dict() преобразует объект пользователя в представление Python, которое затем будет преобразовано в JSON. Мне также нужно взглянуть на обратное направление, когда клиент передает представление пользователя в запросе, а серверу необходимо проанализировать его и преобразовать в объект User. Вот метод from_dict(), который обеспечивает преобразование словаря Python в модель:

app/models.py: Представление в пользовательскую модель.

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

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

В этом случае я решил использовать цикл для импорта любого из полей, которые может установить клиент, а именно usernameemail и about_me. Для каждого поля я проверяю, есть ли значение, указанное в аргументе data, и если есть, я использую метод setattr(), чтобы установить новое значение в соответствующем атрибуте для объекта.

Поле password рассматривается как особый случай, потому что это не поле в объекте. Аргумент new_user определяет, является ли это регистрацией нового пользователя, что означает, что поле password включено. Чтобы установить пароль в пользовательской модели, я вызываю метод set_password(), который создает хэш пароля.

Представление коллекций пользователей

В дополнение к работе с представлениями отдельных ресурсов, этому API потребуется представление коллекций. Это будет формат, используемый, например, когда клиент запрашивает список пользователей или подписчиков. Вот представление для набора пользователей:

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

В этом представлении items представляет собой список пользовательских ресурсов, каждый из которых определен так, как описано в предыдущем разделе. В раздел _meta включены метаданные для коллекции, которые клиент может счесть полезными для представления пользователю элементов управления разбиением на страницы. В разделе _links определены соответствующие ссылки, включая ссылку на саму коллекцию, а также ссылки на предыдущую и следующую страницы, также для того, чтобы помочь клиенту разбить список на страницы.

Генерировать представление коллекции пользователей сложно из-за логики разбивки на страницы, но логика будет общей для других ресурсов, которые я, возможно, захочу добавить в этот API в будущем, поэтому я собираюсь реализовать это представление общим способом, который я затем смогу применить к другим моделям. Еще в главе 16 я был в аналогичной ситуации с полнотекстовым поиском, еще одной функцией, которую я хотел реализовать в общем виде, чтобы ее можно было применять к любым моделям. Решение, которое я использовал, заключалось в реализации класса SearchableMixin, от которого могут наследоваться любые модели, которым требуется полнотекстовый поиск. Я собираюсь использовать ту же идею для этого, поэтому вот новый класс mixin, который я назвал PaginatedAPIMixin:

app/models.py: Разбиение представления на страницы в классе mixin.

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

Метод to_collection_dict() создает словарь с представлением пользовательской коллекции, включая разделы items_meta и _links. Возможно, вам потребуется внимательно изучить метод, чтобы понять, как он работает. Первые три аргумента - это запрос SQLAlchemy, номер страницы и размер страницы. Это аргументы, которые определяют, какие элементы будут возвращены. Реализация использует метод db.paginate() Flask-SQLAlchemy для получения элементов объемом в страницу, как я делал с записями на главной странице, подписки и профиля веб-приложения.

Сложная часть заключается в создании ссылок, которые включают ссылки на текущую, следующую и предыдущую страницы. Я хотел сделать эту функцию универсальной, поэтому я не мог, например, использовать url_for('api.get_users', id=id, page=page) для генерации ссылки . Аргументы для url_for() будут зависеть от конкретной коллекции ресурсов, поэтому я собираюсь полагаться на вызывающую программу, передающую аргумент endpoint для функции просмотра, которая должна использоваться в вызовах url_for(). Поскольку для многих маршрутов в приложении требуются аргументы, мне также нужно фиксировать любые дополнительные аргументы маршрута в kwargs и передавать их в url_for(). Аргументы page и per_page строки запроса приведены явно, поскольку они управляют нумерацией страниц для всех маршрутов API.

Этот смешанный класс необходимо добавить в модель User в качестве родительского класса:

app/models.py: Добавляем PaginatedAPIMixin в пользовательскую модель.

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

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

Обработка ошибок

Страницы ошибок, которые я определил в главе 7, подходят только для пользователя, который взаимодействует с приложением с помощью веб-браузера. Когда API должен возвращать ошибку, это должен быть "машинно-ориентированный" тип ошибки, который клиентское приложение может легко интерпретировать. Таким же образом, как я определил представления для своих ресурсов API в JSON, теперь я собираюсь выбрать представление для сообщений об ошибках API. Вот базовая структура, которую я собираюсь использовать:

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

В дополнение к полезной нагрузке ошибки, я буду использовать коды состояния из протокола HTTP, чтобы указать общий класс ошибки. Чтобы помочь мне генерировать эти ответы об ошибках, я собираюсь написать функцию error_response() в app/api/errors.py:

app/api/errors.py: Ответы на ошибки.

from werkzeug.http import HTTP_STATUS_CODES


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

Эта функция использует удобный словарь HTTP_STATUS_CODES от Werkzeug (базовая зависимость Flask), который предоставляет краткое описательное имя для каждого кода состояния HTTP. Я использую эти имена для поля error в своих представлениях ошибок, так что мне нужно беспокоиться только о числовом коде состояния и необязательном длинном описании. Представление возвращается в Flask, который преобразует его в JSON и отправляет клиенту. Добавлено второе возвращаемое значение с кодом состояния ошибки, чтобы переопределить код состояния по умолчанию 200 (код состояния HTTP для "OK"), который Flask отправляет с ответами.

Наиболее распространенной ошибкой, которую будет возвращать API, будет код 400, который является ошибкой "неправильного запроса". Это ошибка, которая используется, когда клиент отправляет запрос, содержащий неверные данные. Чтобы еще больше упростить генерацию этой ошибки, я собираюсь добавить для нее специальную функцию, которая требует только длинного описательного сообщения в качестве аргумента. Это заполнитель bad_request(), который я добавил ранее.:

app/api/errors.py: Ответ на неправильные запросы.

# ...

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

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

app/api/errors.py: Универсальный обработчик ошибок API.

from werkzeug.exceptions import HTTPException
from app.api import bp

# ...

@bp.errorhandler(HTTPException)
def handle_exception(e):
    return error_response(e.code)

Теперь для обработки всех ошибок на основе класса errorhandler(), который Flask использует для всех ошибок HTTP, будет вызываться декоратор HTTPException для Blueprint API.

Конечные точки пользовательских ресурсов

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

Поиск пользователя

Давайте начнем с запроса на получение отдельного пользователя по заданному id:

app/api/users.py: Возврат пользователя.

from app.models import User

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

Функция просмотра получает id для запрошенного пользователя в качестве динамического аргумента в URL. Вспомогательная функция db.get_or_404() Flask-SQLAlchemy возвращает модель с заданным значением id, если оно существует, но вместо возврата, None когда id не существует, оно прерывает запрос и возвращает клиенту ошибку 404. Это удобно, потому что устраняет необходимость проверять результат запроса, упрощая логику в функциях просмотра.

Наконец, метод to_dict(), который я добавил в User, используется для генерации словаря с представлением ресурсов для выбранного пользователя, который Flask автоматически преобразует в JSON при возврате его клиенту.

Если вы хотите увидеть, как работает этот первый маршрут API, запустите сервер, а затем введите следующий URL-адрес в адресной строке вашего браузера:

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

Это должно показать вам пользователя, представленного в формате JSON. Также попробуйте использовать большое значение id, чтобы увидеть, как метод get_or_404() объекта запроса SQLAlchemy вызывает ошибку 404 (позже я покажу вам, как расширить обработку ошибок, чтобы эти ошибки также возвращались в формате JSON).

Чтобы протестировать этот новый маршрут более подходящим способом, я собираюсь установить HTTPie, HTTP-клиент командной строки, написанный на Python, который упрощает отправку запросов API:

(venv) $ pip install httpie

Теперь я могу запросить информацию о пользователе с помощью id со значением 1 (которым, вероятно, являетесь вы сами) из терминала с помощью следующей команды:

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

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

Получение коллекций пользователей

Теперь я могу полагаться на метод to_collection_dict() класса PaginatedAPIMixin, чтобы вернуть коллекцию всех пользователей:

app/api/users.py: Возврат коллекции всех пользователей.

import sqlalchemy as sa
from flask import request

# ...

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

Для этой реализации я сначала извлекаю page и per_page из строки запроса, используя значения по умолчанию 1 и 10 соответственно, если они не определены. Аргумент per_page имеет дополнительную логику, ограничивающую его значением 100. Предоставлять клиенту управление запросом действительно больших страниц - плохая идея, поскольку это может вызвать проблемы с производительностью сервера. Затем аргументы page и per_page передаются методу to_collection_dict() вместе с запросом, который возвращает всех пользователей. Последний аргумент - api.get_users, это имя конечной точки, которое мне нужно для трех ссылок, используемых в представлении.

Чтобы протестировать эту конечную точку с помощью HTTPie, используйте следующую команду:

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

Следующие две конечные точки - это те, которые возвращают подписчиков и пользователей из подписки. Они довольно похожи на описанную выше.:

app/api/users.py: Список подписчиков и пользователей из подписки.

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


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

Поскольку эти два маршрута специфичны для пользователя, они имеют динамический аргумент id в URL. Аргумент id используется для получения пользователя из базы данных, а затем для предоставления запросов отношений user.followers и user.following для метода to_collection_dict(). Надеюсь, теперь вы понимаете, почему трата немного дополнительного времени для разработки этого метода универсальным способом действительно окупается. Последние два аргумента для to_collection_dict() - это имя конечной точки и id, которое метод собирается принять в качестве дополнительного ключевого аргумента в kwargs, а затем передать его url_for() при создании ссылок в разделе гипермедиа.

Аналогично предыдущему примеру, вы можете использовать эти два маршрута с HTTPie следующим образом:

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

Я должен отметить, что благодаря гипермедиа вам не нужно запоминать эти URL-адреса, поскольку они включены в раздел _links пользовательского представления.

Регистрация новых пользователей

Маршрут запроса POST к /users будет использоваться для регистрации новых учетных записей пользователей. Вы можете увидеть реализацию этого маршрута ниже:

app/api/users.py: Регистрация нового пользователя.

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

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

Этот запрос будет принимать представление пользователя в формате JSON от клиента, предоставленное в теле запроса. Flask предоставляет метод request.get_json() для извлечения тела JSON из запроса и возврата его в виде структуры Python. Этот метод может привести к сбою запроса с кодом состояния 415 (неподдерживаемый тип носителя), если клиент отправляет содержимое не в формате JSON, или с кодом состояния 400 (неверный запрос), если содержимое JSON неверно сформировано, и то, и другое будет обработано handle_http_exception() в app/api/errors.py.

Прежде чем я смогу использовать данные, мне нужно убедиться, что у меня есть вся информация, поэтому я начинаю с проверки наличия трех обязательных полей. Это username, email и password. Если какой-либо из них отсутствует, я использую вспомогательную функцию bad_request() из модуля app/api/errors.py, чтобы вернуть ошибку клиенту. В дополнение к этой проверке мне нужно убедиться, что значения полей username и email еще не используются другим пользователем, поэтому для этого я пытаюсь загрузить пользователя из базы данных по предоставленным имени пользователя и электронной почте, и если какое-либо из них возвращает действительного пользователя, я также возвращаю клиенту сообщение об ошибке.

После прохождения проверки данных я могу легко создать объект user и добавить его в базу данных. Для создания пользователя я полагаюсь на метод from_dict() в модели User. Для аргумента new_user установлено значение True, так что он также принимает поле password, которое обычно не является частью пользовательского представления.

Ответ, который я возвращаю на этот запрос, будет представлением нового пользователя, поэтому метод to_dict() генерирует эту полезную нагрузку. Код состояния для запроса POST, создающего ресурс, должен быть 201, код, который используется при создании нового объекта. Кроме того, протокол HTTP требует, чтобы ответ 201 включал заголовок Location, равный URL нового ресурса, который я могу сгенерировать, используя url_for().

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

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

Редактирование пользователей

Последняя конечная точка, которую я собираюсь использовать в своем API, - это та, которая изменяет существующего пользователя:

app/api/users.py: Изменение пользователя.

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

Для этого запроса я получаю id пользователя как динамическую часть URL-адреса, поэтому я могу загрузить указанного пользователя или вернуть ошибку 404, если он не найден. Обратите внимание, что аутентификация пока отсутствует, поэтому пока API позволит пользователям вносить изменения в учетные записи любых других пользователей. Очевидно, что это проблема безопасности, но я рассмотрю ее в следующем разделе.

Как и в случае с новым пользователем, мне нужно проверить, что поля username и email, предоставленные клиентом, не конфликтуют с другими пользователями, прежде чем я смогу их использовать, но в этом случае проверка сложнее. Прежде всего, эти поля необязательны в этом запросе, поэтому мне нужно проверить, присутствует ли поле. Вторая сложность заключается в том, что клиент может предоставлять для них одно и то же значение, поэтому, прежде чем я проверю, введены ли имя пользователя или адрес электронной почты, мне нужно убедиться, что они отличаются от текущих. Если какая-либо из этих проверок завершается неудачей, я возвращаю клиенту ошибку 400, как и раньше.

После проверки данных я могу использовать метод from_dict() модели User для импорта всех данных, предоставленных клиентом, а затем зафиксировать изменения в базе данных. Ответ на этот запрос возвращает пользователю обновленное представление пользователя с кодом состояния по умолчанию 200.

Вот пример запроса, который редактирует поле about_me с помощью HTTPie:

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

Аутентификация по API

Конечные точки API, которые я добавил в предыдущем разделе, в настоящее время открыты для любых клиентов. Очевидно, что они должны быть доступны только зарегистрированным пользователям, и для этого мне нужно добавить аутентификацию и авторизацию, или сокращенно "AuthN" и "AuthZ". Идея заключается в том, что запросы, отправляемые клиентами, обеспечивают своего рода идентификацию, так что сервер знает, какого пользователя представляет клиент, и может проверить, разрешено ли запрошенное действие для этого пользователя.

Наиболее очевидный способ защитить эти конечные точки API - использовать декоратор @login_required из Flask-Login , но такой подход сопряжен с некоторыми проблемами для конечных точек API. Когда декоратор обнаруживает пользователя без аутентификации, он перенаправляет пользователя на страницу входа в HTML. В API нет понятия HTML или страниц входа в систему, если клиент отправляет запрос с недействительными или отсутствующими учетными данными, сервер должен отклонить запрос, вернув код состояния 401. Сервер не может предполагать, что клиент API является веб-браузером, или что он может обрабатывать перенаправления, или что он может отображать и обрабатывать формы входа в HTML. Когда клиент API получает код состояния 401, он знает, что ему нужно запросить у пользователя учетные данные, но как он это делает, на самом деле не дело сервера.

Токены в пользовательской модели

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

app/models.py: Поддержка пользовательских токенов.

from datetime import timedelta
import secrets

class User(PaginatedAPIMixin, UserMixin, db.Model):
    # ...
    token: so.Mapped[Optional[str]] = so.mapped_column(
        sa.String(32), index=True, unique=True)
    token_expiration: so.Mapped[Optional[datetime]]

    # ...

    def get_token(self, expires_in=3600):
        now = datetime.now(timezone.utc)
        if self.token and self.token_expiration.replace(
                tzinfo=timezone.utc) > now + timedelta(seconds=60):
            return self.token
        self.token = secrets.token_hex(16)
        self.token_expiration = now + timedelta(seconds=expires_in)
        db.session.add(self)
        return self.token

    def revoke_token(self):
        self.token_expiration = datetime.now(timezone.utc) - timedelta(
            seconds=1)

    @staticmethod
    def check_token(token):
        user = db.session.scalar(sa.select(User).where(User.token == token))
        if user is None or user.token_expiration.replace(
                tzinfo=timezone.utc) < datetime.now(timezone.utc):
            return None
        return user

С этим изменением я добавляю атрибут token к модели пользователя, и поскольку мне нужно будет выполнять поиск по нему в базе данных, я делаю его уникальным и индексируемым. Я также добавляю поле token_expiration, в котором указаны дата и время истечения срока действия токена. Это делается для того, чтобы токен не оставался действительным в течение длительного периода времени, что может стать угрозой безопасности.

Я создал три метода для работы с этими токенами. Метод get_token() возвращает токен для пользователя. Токен генерируется с помощью функции secrets.token_hex() из стандартной библиотеки Python. Поле token имеет длину 32 символа, поэтому я должен передать 16 в token_hex(), чтобы результирующий токен имел 16 байт, что при отображении в шестнадцатеричном формате потребовало бы 32 символа. Перед созданием нового токена этот метод проверяет, осталось ли у назначенного в данный момент токена хотя бы минута до истечения срока действия, и в этом случае возвращается существующий токен.

При работе с токенами всегда полезно иметь стратегию немедленного отзыва токена, вместо того, чтобы полагаться только на дату истечения срока действия. Это лучшая практика безопасности, которую часто упускают из виду. Метод revoke_token() делает токен, назначенный пользователю в данный момент, недействительным, просто устанавливая дату истечения срока действия на одну секунду раньше текущего времени.

Метод check_token() - это статический метод, который принимает токен в качестве входных данных и возвращает пользователя, которому принадлежит этот токен, в качестве ответа. Если токен недействителен или срок его действия истек, метод возвращает значение None.

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

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

Запросы токенов

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

Чтобы упростить взаимодействие между клиентом и сервером при использовании аутентификации по токенам, я собираюсь использовать расширение Flask под названием Flask-HTTPAuth. Flask-HTTPAuth устанавливается с помощью pip:

(venv) $ pip install flask-httpauth

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

app/api/auth.py: Базовая поддержка аутентификации.

import sqlalchemy as sa
from flask_httpauth import HTTPBasicAuth
from app import db
from app.models import User
from app.api.errors import error_response

basic_auth = HTTPBasicAuth()

@basic_auth.verify_password
def verify_password(username, password):
    user = db.session.scalar(sa.select(User).where(User.username == username))
    if user and user.check_password(password):
        return user

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

Класс HTTPBasicAuth из Flask-HTTPAuth - это тот, который реализует базовый поток аутентификации. Две необходимые функции настраиваются с помощью декораторов verify_password и error_handler соответственно.

Функция проверки получает имя пользователя и пароль, предоставленные клиентом, и возвращает аутентифицированного пользователя, если учетные данные действительны, или None если нет. Для проверки пароля я полагаюсь на метод check_password() класса User, который также используется Flask-Login во время аутентификации для веб-приложения. После этого аутентифицированный пользователь будет доступен как basic_auth.current_user(), чтобы его можно было использовать в функциях просмотра API.

Функция обработчика ошибок возвращает стандартный ответ об ошибке, который генерируется функцией error_response() в app/api/errors.py. Аргументом status является код состояния HTTP, который в случае неверной аутентификации будет равен 401. Ошибка 401 определяется в стандарте HTTP как "Несанкционированная" ошибка. HTTP-клиенты знают, что при получении этой ошибки отправленный ими запрос необходимо повторно отправить с действительными учетными данными.

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

app/api/tokens.py: Генерация пользовательских токенов.

from app import db
from app.api import bp
from app.api.auth import basic_auth

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

Эта функция просмотра украшена декоратором @basic_auth.login_required из экземпляра HTTPBasicAuth, который проинструктирует Flask-HTTPAuth проверить аутентификацию (с помощью функции проверки, которую я определил выше) и разрешит запуск функции только тогда, когда предоставленные учетные данные действительны. Реализация этой функции просмотра зависит от метода get_token() пользовательской модели для создания токена. Фиксация базы данных выполняется после генерации токена, чтобы гарантировать, что токен и срок его действия будут записаны обратно в базу данных.

Если вы попытаетесь отправить POST-запрос на маршрут /tokens API, произойдет вот что:

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

{
    "error": "Unauthorized"
}

Ответ HTTP включает код состояния 401 и полезную нагрузку ошибки, которую я определил в своей функции basic_auth_error(). Вот тот же запрос, на этот раз включающий базовые учетные данные для аутентификации.:

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

{
    "token": "a3b67df3547a49e6cd338a05c442d666"
}

Теперь код состояния равен 200, что является кодом успешного запроса, а полезная нагрузка включает в себя недавно сгенерированный токен для пользователя. Обратите внимание, что при отправке этого запроса вам нужно будет заменить <username>:<password> на свои собственные учетные данные, те же, которые вы используете в форме входа. Имя пользователя и пароль должны быть снабжены двоеточием в качестве разделителя.

Защита маршрутов API с помощью токенов

Теперь клиенты могут запрашивать токен для использования с конечными точками API, поэтому остается добавить проверку токена к этим конечным точкам. Это то, с чем Flask-HTTPAuth также может справиться за меня. Мне нужно создать второй экземпляр аутентификации на основе класса HTTPTokenAuth и обеспечить обратный вызов проверки токена:

app/api/auth.py: Поддержка аутентификации по токенам.

# ...
from flask_httpauth import HTTPTokenAuth

# ...
token_auth = HTTPTokenAuth()

# ...

@token_auth.verify_token
def verify_token(token):
    return User.check_token(token) if token else None

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

При использовании аутентификации по токену Flask-HTTPAuth использует функцию verify_token, но в остальном аутентификация по токену работает так же, как и базовая аутентификация. Моя функция проверки токена использует User.check_token() для определения пользователя, которому принадлежит предоставленный токен, и возврата его. Как и прежде, возврат None приводит к отклонению клиента с ошибкой аутентификации.

Для защиты маршрутов API с помощью токенов необходимо добавить декоратор @token_auth.login_required:

app/api/users.py: Защита пользовательских маршрутов с помощью аутентификации по токенам.

from flask import abort
from app.api.auth import token_auth

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

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

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

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

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

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

Обратите внимание, что декоратор добавлен ко всем функциям просмотра API, кроме create_user(), которая, очевидно, не может принимать аутентификацию, поскольку пользователь, который будет запрашивать токен, должен быть сперва создан. Также обратите внимание, что запрос PUT, который изменяет пользователей, имеет дополнительную проверку, которая предотвращает попытку пользователя изменить учетную запись другого пользователя. Если я обнаружу, что запрошенный идентификатор пользователя не совпадает с идентификатором аутентифицированного пользователя, то я возвращаю ответ с ошибкой 403, который указывает, что у клиента нет разрешения на выполнение запрошенной операции.

Если вы отправите запрос на любую из этих конечных точек, как показано ранее, вы получите ответ с ошибкой 401. Чтобы получить доступ, вам необходимо добавить заголовок Authorization с токеном, который вы получили из запроса, в /api/tokens. Flask-HTTPAuth ожидает, что токен будет отправлен как "bearer", который может быть отправлен с помощью HTTPie следующим образом:

(venv) $ http -A bearer --auth <token> GET http://localhost:5000/api/users/1

Отзыв токенов

Последняя функция, связанная с токенами, которую я собираюсь реализовать, - это отзыв токена, которую вы можете увидеть ниже:

app/api/tokens.py: Отзыв токенов.

from app.api.auth import token_auth

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

Клиенты могут отправлять запрос DELETE на URL-адрес /tokens, чтобы сделать токен недействительным. Аутентификация для этого маршрута основана на токенах, фактически токен, отправленный в заголовке Authorization, является отозванным. Сама отмена использует вспомогательный метод в классе User, который сбрасывает дату истечения срока действия токена. Сеанс базы данных фиксируется, чтобы это изменение было записано в базу данных. Ответ на этот запрос не имеет тела, поэтому я могу вернуть пустую строку. Второе значение в инструкции return устанавливает код состояния ответа равным 204, который используется для успешных запросов, у которых нет тела ответа.

Вот пример запроса на отзыв токена, отправленного с HTTPie:

(venv) $ http -A bearer --auth <token> DELETE http://localhost:5000/api/tokens

Сообщения об ошибках, понятные для API

Вы помните, что произошло в начале этой главы, когда я попросил вас отправить запрос API из браузера с неверным URL пользователя? Сервер вернул ошибку 404, но эта ошибка была отформатирована как стандартная страница с ошибкой 404 HTML. Многие ошибки, которые может потребоваться возвращать API, могут быть переопределены версиями JSON в схеме элементов API, но есть некоторые ошибки, обрабатываемые Flask, которые по-прежнему проходят через обработчики ошибок, глобально зарегистрированные для приложения, и они продолжают возвращать HTML.

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

Что я хочу сделать, так это изменить глобальные обработчики ошибок приложения, чтобы они использовали согласование содержимого для ответа в HTML или JSON в соответствии с предпочтениями клиента. Это можно сделать с помощью объекта request.accept_mimetypes из Flask:

app/errors/handlers.py: Согласование содержимого для ответов на ошибки.

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

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

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

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

Вспомогательная функция wants_json_response() сравнивает предпочтения JSON или HTML, выбранные клиентом в их списке предпочитаемых форматов. Если значение JSON выше, чем HTML, я возвращаю ответ в формате JSON. В противном случае я верну исходные HTML-ответы на основе шаблонов. Для ответов в формате JSON я собираюсь импортировать вспомогательную функцию error_response из Blueprints API, но здесь я собираюсь переименовать ее в api_error_response(), чтобы было понятно, что она делает и откуда берется.

Последнее слово

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

Примечание от переводчика

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

Комментарии (2)


  1. kompilainenn2
    07.07.2024 20:00
    +1

    Нет ли этого переведенного учебника единым файлом в формате ПДФ?


  1. AzamatKomaev
    07.07.2024 20:00
    +5

    Напишу в последнем вашем посте, который вижу.

    Неужели является проблемой не засорять ленту Хабра, а вместо этого оформить перевод в PDF-файл/GitHub-репозитории, описание и ссылку на которые можно уместить в одну статью?