--- title: Eazy SDK description: Создавайте типизированные SDK для HTTP, WebSocket и браузерных сценариев. layout: landing freshness: false --- Что вы хотите сделать? Eazy SDK отделяет контракт API от транспорта. Выберите путь по своей текущей задаче. ::::{grid} 1 1 2 2 :gutter: 2 :::{grid-item-card} Начать с нуля :link: /start/index :link-type: doc Установите библиотеку и выполните первый запрос. ::: :::{grid-item-card} Пройти цельный пример :link: /tutorial/index :link-type: doc Соберите почтовый SDK с входом, сессией, пагинацией, подписью и шифрованием. ::: :::{grid-item-card} Решить конкретную задачу :link: /guide/index :link-type: doc Найдите практическое руководство по запросам, ответам, авторизации и надёжности. ::: :::{grid-item-card} Подключить интеграцию :link: /integrations/index :link-type: doc Выберите HTTP-клиент и библиотеку моделей, подключите браузер, сгенерируйте пакет или сохраните аккаунты в базе данных. ::: :::: ```{toctree} :hidden: :caption: Начало работы :maxdepth: 2 start/index start/installation start/quickstart start/concepts start/next ``` ```{toctree} :hidden: :caption: Разбор на примере :maxdepth: 2 tutorial/index tutorial/login tutorial/login-failures tutorial/captcha tutorial/session tutorial/messages tutorial/send tutorial/encryption tutorial/assembly ``` ```{toctree} :hidden: :caption: Руководство :maxdepth: 3 guide/index guide/requests/index guide/requests/declarative guide/requests/path guide/requests/query guide/requests/headers guide/requests/cookies guide/requests/json guide/requests/form guide/requests/multipart guide/requests/bytes guide/requests/representation guide/requests/values guide/requests/addresses guide/responses/index guide/responses/success guide/responses/errors guide/responses/html guide/responses/redirects guide/responses/envelope guide/auth/index guide/auth/basic guide/auth/bearer guide/auth/jwt guide/auth/api-key guide/auth/cookie guide/auth/combined guide/auth/login guide/auth/session guide/auth/placed guide/auth/cookies-session guide/auth/refresh guide/pagination guide/signing guide/encryption guide/protection guide/reliability/retry guide/reliability/redirect guide/reliability/rate-limit guide/multi-service guide/dependencies guide/middleware guide/protocols guide/websocket guide/testing ``` ```{toctree} :hidden: :caption: Интеграции :maxdepth: 3 integrations/http/index integrations/index integrations/http/httpx integrations/http/requests integrations/http/curl-cffi integrations/http/custom-handler integrations/browser/index integrations/browser/playwright integrations/browser/pydoll integrations/browser/outcomes integrations/browser/login integrations/browser/browser-pool integrations/models/index integrations/models/pydantic integrations/models/msgspec integrations/models/dataclass integrations/models/adaptix integrations/documents/html integrations/documents/xml integrations/generators/openapi integrations/generators/asyncapi integrations/accounts/index integrations/accounts/registration integrations/accounts/verification integrations/accounts/sqlmodel integrations/protection/cloudflare integrations/protection/turnstile integrations/protection/recaptcha ``` ```{toctree} :hidden: :caption: Архитектура :maxdepth: 2 architecture/index architecture/request-path architecture/attempts architecture/response-cases architecture/identity architecture/extension-points architecture/browser ``` ```{toctree} :hidden: :caption: Справочник :maxdepth: 2 reference/api/index reference/api/api-methods reference/api/clients reference/api/extensions reference/api/testing reference/api/request reference/api/response reference/api/models-codecs reference/api/auth reference/api/session reference/api/registration reference/api/verification reference/api/dependencies reference/api/middleware reference/api/signing reference/api/crypto reference/api/protection reference/api/openapi reference/api/sqlmodel reference/api/browser reference/api/xml reference/migration reference/changelog ``` --- title: Начало работы description: Установка Eazy SDK и первый запрос. template: splash freshness: false hero: tagline: Установите Eazy SDK, выберите клиент, выполните первый запрос. actions: - text: Установить link: /start/installation/ icon: right-arrow - text: Выполнить запрос link: /start/quickstart/ variant: minimal --- ::::{grid} 1 1 2 2 :gutter: 2 :::{grid-item-card} Установка :link: /start/installation :link-type: doc Команды для uv и pip, таблица дополнений. ::: :::{grid-item-card} Быстрый старт :link: /start/quickstart :link-type: doc Первая операция и запрос через HTTPX. ::: :::{grid-item-card} Основные понятия :link: /start/concepts :link-type: doc Операция, роутер, клиент, корень SDK и Identity. ::: :::{grid-item-card} Что дальше :link: /start/next :link-type: doc Маршрут по задачам и файлы для LLM. ::: :::: ## После первых шагов ::::{grid} 1 1 3 3 :gutter: 2 :::{grid-item-card} Разбор на примере :link: /tutorial/index :link-type: doc SDK почты: HTTP и браузер на одном учебном сайте. ::: :::{grid-item-card} Клиенты :link: /integrations/http/index :link-type: doc HTTPX, Requests, curl_cffi или свой обработчик. ::: :::{grid-item-card} Руководства :link: /guide/index :link-type: doc Найдите страницу по своей задаче. ::: :::: --- title: Основные понятия description: Как операция, роутер, клиент, корень SDK и Identity делят ответственность. sources: - eazy_sdk.api - eazy_sdk.Client - eazy_sdk.root - eazy_sdk.Identity api_fingerprint: "sha256:337b9d5ffc11fb455e743f9695ff536419b08f94bbf0d6e0c5e67b6c965b9c89" --- Eazy SDK разделяет описание API, доставку запроса и данные пользователя. Это позволяет менять HTTP-клиент или собирать несколько сервисов, не переписывая операции. У каждого объекта одна роль. | Объект | Отвечает на вопрос | Что в нём хранится | |---|---|---| | Операция | Что отправить и какой результат вернуть? | Поля запроса, метод, путь, типы ответов | | Роутер | Какие операции принадлежат одному сервису? | Операции, адрес и общие правила сервиса | | Клиент | Как доставить байты? | Обработчик, соединения, повторы, ограничения транспорта | | Корень SDK | Как собрать публичный SDK? | Группы роутеров и привязки к клиентам | | `Identity` | От чьего имени идёт вызов? | Авторизация, ключи подписи, зависимости, наблюдатель | ## Операция описывает один вызов Операцию объявляют неизменяемым классом. Её поля становятся аргументами публичного метода и указывают, куда попадёт каждое значение: в путь, строку запроса, заголовок или тело. Объявление транспорта задаёт HTTP-метод и путь, а параметр типа у `HttpOperation` задаёт результат. При вызове роутера SDK создаёт значение операции, проверяет план запроса и передаёт его в общий конвейер исполнения. Поэтому сигнатура метода известна редактору кода до первого запроса. ## Роутер описывает сервис `SyncApi` и `AsyncApi` публикуют операции через `op(...)`. Роутер также хранит контракт, общий для сервиса: адрес, ошибки, требования к авторизации, подпись, шифрование и формат данных. Эти правила наследуются операциями, если более точное объявление их не переопределило. Один роутер можно создать прямо над готовым клиентом. Такой вариант подходит небольшому SDK с одной группой методов. ## Клиент доставляет байты `Client` и `AsyncClient` связывают SDK с обработчиком HTTP. Клиент управляет соединениями, повторами, перенаправлениями, лимитом частоты и защитой транспорта. Он не решает, какие методы есть у сервиса и кто выполняет запрос. Жизненный цикл клиента задаёт код, который его создал. Роутер или корень, получивший готовый клиент через конструктор, не закрывает его за вызывающий код. ## Корень собирает несколько роутеров `SyncRoot` и `AsyncRoot` образуют публичную точку входа в большой SDK. `api_group(...)` добавляет роутер как именованную группу, например `sdk.messages` или `sdk.auth`. Сам корень операций не объявляет. По умолчанию группы используют клиент корня. Привязка через `bind(...)` может дать отдельному сервису другой клиент или адрес. Группы создаются при первом обращении и затем используются повторно. ## Identity представляет вызывающую сторону `Identity` хранит то, что относится к пользователю или окружению вызова: настроенную авторизацию, источник ключей подписи, реестр зависимостей и наблюдатель. Транспортные данные, такие как пул соединений, прокси и сессия защиты, остаются у клиента. Один `Identity`, переданный корню, обслуживает все его роутеры. Чтобы работать от имени другого пользователя, создайте другой `Identity`; новый пул соединений для этого не нужен. ## Как объекты связаны Прикладной код вызывает операцию на роутере. Роутер добавляет правила сервиса, корень выбирает для него клиент, а `Identity` даёт данные вызывающей стороны. Клиент выполняет подготовленный запрос и возвращает ответ в общий конвейер. Там SDK сопоставляет ответ с объявленным типом результата. Если SDK состоит из одного роутера, корень не обязателен. Если запросы не требуют авторизации, подписи, внедрения зависимостей или наблюдения, можно не передавать `Identity`. ## Дальше - {doc}`Повторите устройство первого запроса `. - {doc}`Соберите несколько сервисов в один SDK `. - {doc}`Выберите HTTP-клиент `. --- title: Установка description: Установите Eazy SDK с HTTP-клиентом и библиотекой моделей для первого проекта. freshness: false --- Первому JSON SDK нужны ядро, HTTPX и поддержка моделей Pydantic. Установите их командой `uv add "eazy-sdk-core[httpx,pydantic]"`. Пакет ядра на PyPI называется `eazy-sdk-core`, а в коде импортируется как `eazy_sdk`. Пока выходят только альфа-версии, pip требует флаг `--pre`, а uv - параметр `--prerelease allow`. Команда добавляет синхронный и асинхронный адаптер HTTPX. Само ядро не выбирает HTTP-клиент и библиотеку моделей за приложение. В проекте с pip набор зависимостей остаётся тем же: `python -m pip install "eazy-sdk-core[httpx,pydantic]"`. ## Выберите нужные дополнения Не устанавливайте полный набор для каждой задачи. Добавьте только транспорт, формат данных или интеграцию, которые нужны SDK. | Задача | Дополнение | Команда для uv | |---|---|---| | HTTP через HTTPX | `eazy-sdk-core[httpx]` | `uv add "eazy-sdk-core[httpx]"` | | Синхронный HTTP через Requests | `eazy-sdk-core[requests]` | `uv add "eazy-sdk-core[requests]"` | | HTTP через curl_cffi | `eazy-sdk-core[curl-cffi]` | `uv add "eazy-sdk-core[curl-cffi]"` | | Модели Pydantic v2 | `eazy-sdk-core[pydantic]` | `uv add "eazy-sdk-core[pydantic]"` | | Модели msgspec Struct | `eazy-sdk-core[msgspec]` | `uv add "eazy-sdk-core[msgspec]"` | | Разбор HTML | `eazy-sdk-core[html]` | `uv add "eazy-sdk-core[html]"` | | Аккаунты и регистрация | `eazy-sdk-core[accounts]` | `uv add "eazy-sdk-core[accounts]"` | | Хранилище SQLModel | `eazy-sdk-core[sqlmodel]` | `uv add "eazy-sdk-core[sqlmodel]"` | | WebSocket | `eazy-sdk-core[websocket]` | `uv add "eazy-sdk-core[websocket]"` | | Браузерный SDK с Playwright | `eazy-sdk-core[browser,playwright]` | `uv add "eazy-sdk-core[browser,playwright]"` | | Генератор OpenAPI | `eazy-sdk-core[openapi,yaml]` | `uv add "eazy-sdk-core[openapi,yaml]"` | | Генератор AsyncAPI | `eazy-sdk-core[asyncapi,yaml]` | `uv add "eazy-sdk-core[asyncapi,yaml]"` | | Готовые описания защит | `eazy-sdk-core[presets]` | `uv add "eazy-sdk-core[presets]"` | | XML | `eazy-sdk-core[xml]` | `uv add "eazy-sdk-core[xml]"` | | Модели Adaptix | `eazy-sdk-core[adaptix]` | `uv add "eazy-sdk-core[adaptix]"` | Все интеграции входят в один пакет `eazy-sdk-core`. Дополнение ставит только сторонние библиотеки, которые нужны интеграции, поэтому несколько дополнений перечисляются через запятую в одной команде. Дополнение `yaml` у генераторов нужно для файлов YAML. Для спецификации JSON достаточно `openapi` или `asyncapi`. У браузерной интеграции вместо `playwright` можно выбрать `selenium`, `camoufox` или `pydoll`. `eazy-sdk-core[all]` ставит основные HTTP-интеграции, модели и средства работы с данными, но намеренно не ставит браузерный драйвер. Браузер и его двоичные файлы выбирает приложение. ## Проверьте установку В проекте с uv запустите `uv run python -c "import eazy_sdk, httpx, pydantic; print(eazy_sdk.__version__)"`. В активированном окружении pip уберите префикс `uv run` и начните команду с `python`. Результат содержит только установленную версию: ```text 0.2.0a15 ``` Если вы выбрали другой HTTP-клиент или библиотеку моделей, замените `httpx` и `pydantic` в строке импорта. Трассировки ошибки быть не должно. ## Требования - Python 3.13 или новее. - Менеджер пакетов с поддержкой дополнений Python. Команды выше даны для uv и pip. - Один поддерживаемый HTTP-клиент для HTTP SDK. Список возможностей клиентов находится в {doc}`руководстве по HTTP-интеграциям `. - Драйвер для браузерного SDK. После установки Playwright загрузите Chromium командой `uv run playwright install chromium`. В активированном окружении pip используйте `playwright install chromium`. ## Дальше - {doc}`Соберите первый запрос `. - {doc}`Выберите HTTP-клиент `. - {doc}`Подключите браузерный драйвер `. --- title: Что дальше description: Выберите раздел документации по задаче и подключите llms.txt к помощнику. freshness: false --- После быстрого старта не нужно читать документацию подряд. Найдите свою задачу в таблице и переходите к соответствующему разделу. | Задача | Куда идти | |---|---| | Пройти один SDK от входа до отправки письма | {doc}`Разбор на примере ` | | Объявить параметры, заголовки или тело запроса | {doc}`Запросы ` | | Разобрать успешный ответ или ошибку API | {doc}`Ответы ` | | Добавить ключ, токен, cookie или вход по паролю | {doc}`Авторизация ` | | Выбрать HTTPX, Requests или curl_cffi | {doc}`HTTP-интеграции ` | | Работать со страницами через Playwright или Pydoll | {doc}`Браузерный SDK ` | | Подключить Pydantic, msgspec, dataclass, HTML или XML | {doc}`Модели и документы ` | | Собрать несколько сервисов в один SDK | {doc}`Мультисервисный SDK ` | | Найти точный класс, аргумент или тип | {doc}`Справочник API ` | ## Если API уже описан схемой Для OpenAPI используйте {doc}`генератор OpenAPI `. Он создаёт Python-пакет SDK из схемы и сохраняет типы запросов и ответов. Для AsyncAPI и WebSocket начните с {doc}`руководства по WebSocket `. Там показаны генерация пакета, подключение клиента и типизированные сообщения. ## Документация для LLM Сборка сайта создаёт два текстовых файла из тех же страниц: - llms.txt содержит краткое описание проекта и ссылки на страницы; - llms-full.txt содержит полный текст документации в одном файле. Выбор зависит от инструмента. Передайте помощнику `llms.txt`, если он умеет открывать ссылки: так в контекст попадут только нужные страницы. `llms-full.txt` подходит для локального поиска или инструмента, который не может переходить по ссылкам. Оба файла обновляются при каждой сборке сайта, поэтому отдельную копию документации поддерживать не нужно. Для точной сигнатуры после объясняющей страницы откройте {doc}`справочник API `. Он отделяет публичные имена от внутренних деталей реализации. ## Дальше - {doc}`Вернитесь к основным понятиям `. - {doc}`Откройте карту руководств `. - {doc}`Найдите точное имя в справочнике API `. --- title: Быстрый старт description: Объявите первый метод почтового SDK и получите типизированное письмо. sources: - eazy_sdk.api - eazy_sdk.Client - eazy_sdk.request - eazy_sdk.response api_fingerprint: "sha256:1209a3ff26b3a7d898d814af20bb067e19b34b2667154c406a14105b9cf15e14" --- Получим письмо `42` из учебного сервиса `mail.example`. Выберите библиотеку моделей, которую уже использует проект. Объявление HTTP-операции и вызов SDK во всех трёх вариантах одинаковы. Команды установки ниже относятся к вашему проекту. Команды запуска проверяют готовые файлы из репозитория Eazy SDK: клонируйте репозиторий и выполняйте их из его корня. Эти файлы подключены к локальному учебному сайту, поэтому не отправляют запросы во внешнюю сеть. :::::{tab-set} :sync-group: model-library ::::{tab-item} Pydantic :sync: pydantic Установите зависимости командой `uv add "eazy-sdk-core[httpx,pydantic]"`. ```{literalinclude} ../../../../../examples/mail/http/quickstart_pydantic.py :language: python :start-after: "# region docs: quickstart-pydantic" :end-before: "# endregion docs: quickstart-pydantic" ``` `Message` наследует `BaseModel` и запрещает изменение полей после проверки ответа. :::{dropdown} Файл примера целиком ```{literalinclude} ../../../../../examples/mail/http/quickstart_pydantic.py :language: python ``` ::: Запуск: `uv run python -m examples.mail.http.quickstart_pydantic`. ```text ada@mail.example: Планы на пятницу ``` :::: ::::{tab-item} dataclass :sync: dataclass Стандартной библиотеке нужен только HTTP-клиент: `uv add "eazy-sdk-core[httpx]"`. ```{literalinclude} ../../../../../examples/mail/http/quickstart_dataclass.py :language: python :start-after: "# region docs: quickstart-dataclass" :end-before: "# endregion docs: quickstart-dataclass" ``` `@dataclass(frozen=True, slots=True)` даёт неизменяемую модель без внешней зависимости. :::{dropdown} Файл примера целиком ```{literalinclude} ../../../../../examples/mail/http/quickstart_dataclass.py :language: python ``` ::: Запуск: `uv run python -m examples.mail.http.quickstart_dataclass`. ```text ada@mail.example: Планы на пятницу ``` :::: ::::{tab-item} msgspec :sync: msgspec Установите зависимости командой `uv add "eazy-sdk-core[httpx,msgspec]"`. ```{literalinclude} ../../../../../examples/mail/http/quickstart_msgspec.py :language: python :start-after: "# region docs: quickstart-msgspec" :end-before: "# endregion docs: quickstart-msgspec" ``` `msgspec.Struct` проверяет ответ и создаёт неизменяемый объект `Message`. :::{dropdown} Файл примера целиком ```{literalinclude} ../../../../../examples/mail/http/quickstart_msgspec.py :language: python ``` ::: Запуск: `uv run python -m examples.mail.http.quickstart_msgspec`. ```text ada@mail.example: Планы на пятницу ``` :::: ::::: ## Что произошло `GetMessage` хранит описание GET-запроса. Поле `message_id: Path[int]` принимает число от вызывающего кода и подставляет его в `/messages/{message_id}`. Тип `HttpOperation[Message]` выбирает модель успешного ответа. Библиотека моделей меняет только объявление `Message`. `MailApi` публикует операцию под именем `message` и задаёт адрес сервиса. Вызов `MailApi(client).message(message_id=42)` имеет ту же сигнатуру, что и класс `GetMessage`, поэтому редактор кода знает имя и тип аргумента до запуска программы. Функция `quickstart_client()` подключает пример к локальному учебному сайту из репозитория. В прикладном SDK приложение создаёт `Client`, а роутер получает его готовым. Выбор HTTPX, Requests или curl_cffi не меняет объявление операции. ## Следующий запрос Для POST добавьте поля тела с маркером `JsonField`. Вложенную структуру внешнего JSON собирает `BodyProjection`; вызывающему коду по-прежнему передаются обычные именованные аргументы. Эти механизмы разобраны в {doc}`руководстве по запросам `. Когда методов станет несколько, объедините роутеры в корень SDK. Дальше можно изучить {doc}`структуру запросов `, {doc}`HTTP-интеграции ` или {doc}`всё руководство `. --- title: Что строим description: Учебный SDK почты с HTTP- и браузерной линиями на одном локальном сайте. freshness: false --- В этом разделе мы соберём SDK для учебного сервиса «Почта» на `mail.example`. Имя и домен вымышлены, а форма входа, список писем и токен в HTML взяты из настоящего почтового API. Код работает локально и не обращается к внешнему сервису. Один сайт обслуживает две линии. HTTP-линия вызывает JSON API, браузерная заполняет формы и читает признаки страниц. Они используют общие аккаунты, сессии и письма, поэтому результат можно сверять между транспортами. ## Что в примере учебное | Часть сайта | Основа | Зачем она нужна в разборе | |---|---|---| | Конверт ответа с `status` и `body` | Форма настоящего API | Показать варианты типизированного ответа | | Токен в HTML страницы входящих | Форма настоящего API | Передать браузерную сессию HTTP-клиенту | | Смещение и лимит списка писем | Форма настоящего API | Разобрать пагинацию со смещением | | Краткие данные пользователя через `GET /api/v1/user/short` | Форма настоящего API | Проверить сессию отдельным запросом | | Проверка TLS-отпечатка клиента | Форма настоящего API | Объяснить выбор `curl_cffi` | | Вход в два шага и переход ко второму фактору | Форма настоящего API | Разобрать несколько страниц одного входа | | Тост, диалог и отсутствие признака после отправки | Форма настоящего API | Различить три исхода композера | | Капча во время входа | Учебное добавление | Показать решатель и повтор шага | | Курсор и готовая ссылка следующей страницы | Учебное добавление | Сравнить три стратегии пагинации | | Подпись и шифрование отправки | Учебное добавление | Показать клиентскую подготовку запроса | | Отдельные исходы блокировки и неизвестного аккаунта | Учебное добавление | Разобрать отказы на точных признаках | :::::{tab-set} :sync-group: transport ::::{tab-item} HTTP :sync: http HTTP-линия обращается к сайту через клиент Eazy SDK. Все ответы входа, сессии и писем приходят от одного `MailSite`. | Метод и путь | Задача | |---|---| | `POST /login/identify` | Найти аккаунт и начать вход | | `POST /login/password` | Проверить пароль или запросить капчу | | `POST /login/otp` | Проверить второй фактор и выдать сессию | | `POST /api/v1/session/refresh` | Обновить истёкшую сессию | | `GET /api/v1/user/short` | Получить текущего пользователя | | `GET /api/v1/client/fingerprint` | Проверить TLS-отпечаток клиента | | `GET /api/v1/threads/status/smart` | Прочитать письма с пагинацией | | `POST /api/v1/messages/send` | Отправить подписанное письмо | | `GET /api/v1/messages/sent` | Найти письмо после неопределённого исхода | В следующих главах эти ручки станут отдельными операциями и роутерами. :::: ::::{tab-item} Браузер :sync: browser Браузерная линия видит те же данные через страницы. Переходы между формами становятся типизированными состояниями SDK. | Страница или признак | Задача | |---|---| | `/login/` и поле `email` | Ввести адрес аккаунта | | Страница с полем `password` | Отправить пароль | | `/login/otp` и поле `code` | Подтвердить второй фактор | | Страница с `data-page="captcha"` | Решить капчу и повторить текущий шаг | | `/inbox/` и `data-page="mailbox"` | Подтвердить вход и прочитать токен сессии | | Коллекция `data-collection="messages"` | Получить письма и подгрузить следующую часть | | `/compose/` и форма `data-composer` | Отправить письмо и различить три исхода | Подпись и шифрование выполняет JavaScript самой учебной страницы. Браузерная операция управляет формой и читает исход; клиентское преобразование Eazy SDK разберёт HTTP-линия. :::: ::::: ## Как читать разбор Выбор вкладки сохраняется между главами. Начните с привычного транспорта, а второй открывайте, когда нужно сравнить объявления или передать сессию из браузера HTTP-клиенту. Каждый фрагмент берётся из запускаемого файла в `examples/mail/`. Код сайта и тестовая обвязка остаются за пределами страницы; рядом с примером будут только запрос, ответ и фактический вывод. ## Дальше - {doc}`Сверьте первый HTTP-запрос `. - {doc}`Посмотрите основные понятия SDK `. --- title: Сборка HTTP- и браузерных роутеров description: Соберите один корень SDK и передайте браузерную сессию клиенту curl_cffi. freshness: false --- Корень называет доступные роутеры и выбирает для каждого клиент. `MailSdk` собирает HTTP-группы аккаунта и писем. `BrowserMailSdk` наследует их без повторного объявления и добавляет `MailboxApi`, который работает на странице Chromium. | Решение сборки | HTTP-запуск | Браузерный запуск | |---|---|---| | Корень | `MailSdk` | `BrowserMailSdk` | | HTTP-клиент | HTTPX | curl_cffi с профилем Chrome 124 | | HTTP-роутеры | `AccountApi`, `MessagesApi` | те же классы роутеров | | Браузерный роутер | нет | `MailboxApi` через `bind(...)` | | Сессия HTTP | статический Bearer для учебного запуска | токен из `mail_session` после одного входа в Chromium | :::::{grid} 1 1 2 2 :class-container: http-exchange :gutter: 2 ::::{grid-item} :class: http-panel http-panel--request **→ Request** · ``GET /api/v1/user/short`` ```text Authorization: Bearer session-ada ``` :::: ::::{grid-item} :class: http-panel http-panel--response **← Response** · ``200 OK`` ```json { "status": "ok", "body": {"email": "ada@mail.example", "display_name": "Ada"} } ``` :::: ::::: :::::{tab-set} :sync-group: transport ::::{tab-item} HTTP :sync: http `api_group(...)` публикует роутер на корне. Объявление не создаёт второй клиент и не копирует операции: группа получает клиент, `Identity` и сериализацию во время сборки корня. ```{literalinclude} ../../../../../examples/mail/sdk.py :language: python :start-after: "# region docs: mail-sdk-routers" :end-before: "# endregion docs: mail-sdk-routers" ``` HTTP-запуск передаёт одному `MailSdk` общий `Identity`. `account.current()` использует правило роутера, пагинация и зашифрованная отправка сохраняют правила своих операций. ```{literalinclude} ../../../../../examples/mail/http/assembly.py :language: python :start-after: "# region docs: http-sdk-flow" :end-before: "# endregion docs: http-sdk-flow" ``` :::{dropdown} Файл корня целиком ```{literalinclude} ../../../../../examples/mail/sdk.py :language: python ``` ::: :::{dropdown} Файл HTTP-запуска целиком ```{literalinclude} ../../../../../examples/mail/http/assembly.py :language: python ``` ::: Запуск: `uv run python -m examples.mail http`. ```text transport: httpx routers: account, messages user: ada@mail.example message ids: [42, 43] encrypted send: 100 ``` :::: ::::{tab-item} Браузер :sync: browser `BrowserLogin` входит один раз и отдаёт состояние контекста. Учебный сайт принимает значение куки `mail_session` как Bearer-токен, поэтому пример берёт его из состояния и передаёт в `MAIL_BEARER.static(...)` для тех же HTTP-операций. Реальный `AsyncClient.curl_cffi(...)` отправляет запросы, а `bind(...)` оставляет `MailboxApi` на клиенте Chromium. Браузерный роутер ждёт `data-page="mailbox"`, а карта писем работает с коллекцией внутри него. Обе метки приходят из фактического HTML учебного сайта: ```{literalinclude} ../../../../../examples/mail/site/app.py :language: html :start-after: "" :end-before: "" ``` ```{literalinclude} ../../../../../examples/mail/browser/assembly.py :language: python :start-after: "# region docs: browser-sdk-flow" :end-before: "# endregion docs: browser-sdk-flow" ``` `mail_origin(...)` лишь поднимает локальную границу учебного `MailSite`. В приложении `base_url` указывает на сервис, а выбор `impersonate="chrome124"` и устройство корня остаются такими же. :::{dropdown} Файл браузерного запуска целиком ```{literalinclude} ../../../../../examples/mail/browser/assembly.py :language: python ``` ::: Запуск: `uv run python -m examples.mail browser`. ```text transport: curl_cffi + Chromium browser logins: 1 user via browser session: ada@mail.example message ids: [42, 43] browser router: mailbox ``` :::: ::::: :::{note} Корень не владеет клиентом, переданным через `bind(...)`. Поэтому браузерный клиент закрывается в `finally`, а HTTP-клиент, созданный контекстным менеджером, закрывается своим менеджером. ::: ## Дальше - {doc}`Вернитесь к жизненному циклу сессии `. - {doc}`Сверьте карту всего учебного сайта `. - {doc}`Выберите следующий раздел документации `. --- title: Капча на входе description: Решите капчу и продолжите тот же шаг пароля без второго пути входа. freshness: false --- Аккаунт `captcha@mail.example` принимает пароль, но до второго фактора требует проверку `mail-login`. Эта капча придумана для учебного сайта. Решатель меняет состояние текущего клиента, после чего шаг пароля продолжается. | Линия | Что делает решатель | Что продолжает работу | |---|---|---| | HTTP | кладёт `login_clearance=solved` в cookie | тот же POST повторяется по политике replay | | Браузер | нажимает кнопку виджета | тот же вызов операции дожидается страницы кода | :::::{grid} 1 1 2 2 :class-container: http-exchange :gutter: 2 ::::{grid-item} :class: http-panel http-panel--request **→ Request** · ``POST /login/password`` ```json { "login_id": "login:captcha@mail.example", "password": "correct" } ``` :::: ::::{grid-item} :class: http-panel http-panel--response **← Response** · ``403 Forbidden`` ```json {"kind": "captcha", "site_key": "mail-login"} ``` :::: ::::: :::::{tab-set} :sync-group: transport ::::{tab-item} HTTP :sync: http `LoginCaptchaGuard` распознаёт ответ до разбора обычных ошибок. Политика `rejected_before_execution(max_replays=1)` разрешает ровно один повтор: сервер отклонил запрос до выполнения входа, поэтому повтор не дублирует результат. ```{literalinclude} ../../../../../examples/mail/http/captcha_probe.py :language: python :start-after: "# region docs: http-captcha-guard" :end-before: "# endregion docs: http-captcha-guard" ``` После решения cookie попадает в тот же HTTP-клиент, а координатор заново готовит запрос пароля. ```{literalinclude} ../../../../../examples/mail/http/captcha.py :language: python :start-after: "# region docs: http-captcha-flow" :end-before: "# endregion docs: http-captcha-flow" ``` :::{dropdown} Файлы примера целиком ```{literalinclude} ../../../../../examples/mail/http/captcha_probe.py :language: python ``` ```{literalinclude} ../../../../../examples/mail/http/captcha.py :language: python ``` ::: Запуск: `uv run python -m examples.mail.http.captcha`. ```text challenge solved: mail-login password requests: 2 replay cookie: login_clearance=solved next step: OtpStep ``` :::: ::::{tab-item} Браузер :sync: browser После отправки пароля `MailSite` возвращает этот фрагмент: ```html
Подтвердите вход
``` `Handle` проверяется раньше `Failure`. Он нажимает кнопку решения, страница переходит к форме кода, и исход `to=OtpStep` завершает исходный вызов `password(...)`. ```{literalinclude} ../../../../../examples/mail/browser/captcha.py :language: python :start-after: "# region docs: browser-captcha-operation" :end-before: "# endregion docs: browser-captcha-operation" ``` ```{literalinclude} ../../../../../examples/mail/browser/captcha.py :language: python :start-after: "# region docs: browser-captcha-flow" :end-before: "# endregion docs: browser-captcha-flow" ``` :::{dropdown} Файл примера целиком ```{literalinclude} ../../../../../examples/mail/browser/captcha.py :language: python ``` ::: Запуск: `uv run python -m examples.mail.browser.captcha`. ```text challenge solved: mail-login password submits: 1 same operation: OtpStep ``` :::: ::::: :::{note} Повтор HTTP ограничен политикой replay. Браузер не отправляет пароль второй раз: обработчик виджета остаётся внутри уже начатой операции и ждёт её обычный исход. ::: ## Дальше - {doc}`Передайте вход жизненному циклу сессии `. - {doc}`Сверьте остальные исходы входа `. --- title: Шифрование полей и всего тела description: Преобразуйте выбранные поля, зашифруйте сериализованные байты и подпишите шифротекст. freshness: false --- Письмо проходит две стадии. Сначала Eazy SDK преобразует `subject` и `body` внутри модели, затем JSON-кодек создаёт байты и второй алгоритм шифрует их целиком. HMAC из предыдущей главы видит уже шифротекст, поэтому сервер проверяет ровно те байты, которые получил. | Стадия | Вход | Результат | |---|---|---| | Преобразование полей | `NewMessage.subject`, `NewMessage.body` | строки с префиксом `mail-field:` | | JSON-кодек | модель с преобразованными полями | компактный UTF-8 JSON | | Преобразование байтов | сериализованный JSON | байты с префиксом `mail-body:` | | Подпись | окончательные байты | `X-Mail-Signature` по шифротексту | | Входящий ответ | зашифрованные байты | обычный `SendResult` после обратных стадий | :::::{grid} 1 1 2 2 :class-container: http-exchange :gutter: 2 ::::{grid-item} :class: http-panel http-panel--request **→ Request** · ``POST /api/v1/messages/send`` ```text Authorization: Bearer session-1 Content-Type: application/vnd.mail.encrypted+json X-Mail-Signature: cebf73d9b5258813cd412be5173cf14633cf6cf26812c08ddbfc595cb58ddb1a mail-body:eyJib2R5IjoibWFpbC1maWVsZDowSnJRdTlHTzBZZlF1Q0RRdjlHQTBMalFzdEMxMExmUmc5R0NJTkN5SU5DLzBZL1JndEM5MExqUmh0R0RMZz09IiwicmVjaXBpZW50IjoiZ3JhY2VAbWFpbC5leGFtcGxlIiwic3ViamVjdCI6Im1haWwtZmllbGQ6MEtIUXRkQzYwWURRdGRHQzBMM1FzTkdQSU5DeTBZSFJndEdBMExYUmg5Q3cifQ== ``` :::: ::::{grid-item} :class: http-panel http-panel--response **← Response** · ``200 OK`` ```text Content-Type: application/vnd.mail.encrypted+json mail-body:eyJzdGF0dXMiOiJvayIsImJvZHkiOnsib3V0Y29tZSI6InNlbnQiLCJtZXNzYWdlX2lkIjoxMDB9fQ== ``` :::: ::::: :::::{tab-set} :sync-group: transport ::::{tab-item} HTTP :sync: http Алгоритмы принадлежат приложению. Учебные реализации кодируют значение в Base64 с различимым префиксом; `CryptoContext` они принимают по тому же протоколу, что и настоящий алгоритм из KMS или хранилища ключей. ```{literalinclude} ../../../../../examples/mail/crypto.py :language: python :start-after: "# region docs: mail-demo-ciphers" :end-before: "# endregion docs: mail-demo-ciphers" ``` Профиль задаёт порядок стадий и пределы размера. `encrypt_field(...)` получает тип модели и путь, который проверяет типизатор. `encrypt_encoded(...)` работает позже, уже с сериализованными байтами. Обратное преобразование байтов нужно для зашифрованного ответа. ```{literalinclude} ../../../../../examples/mail/http/encryption.py :language: python :start-after: "# region docs: mail-encryption-profile" :end-before: "# endregion docs: mail-encryption-profile" ``` Операция связывает профиль с контрактом представления. Внешний `Content-Type` принадлежит зашифрованному формату, а `clear_content_type` говорит декодеру, что после расшифровки лежит JSON. `plaintext_statuses` оставляет ответы `401` и `403` открытыми: сервер возвращает такие отказы до расшифровки тела. Подпись остаётся той же, но исполняется после обеих исходящих стадий. ```{literalinclude} ../../../../../examples/mail/http/encryption.py :language: python :start-after: "# region docs: encrypted-mail-operation" :end-before: "# endregion docs: encrypted-mail-operation" ``` После отправки обычный GET ищет письмо по открытой теме. Результат подтверждает, что сайт расшифровал поле перед сохранением, а вызывающий получил обычную модель ответа. ```{literalinclude} ../../../../../examples/mail/http/encryption.py :language: python :start-after: "# region docs: encrypted-mail-flow" :end-before: "# endregion docs: encrypted-mail-flow" ``` :::{dropdown} Файл операции целиком ```{literalinclude} ../../../../../examples/mail/http/encryption.py :language: python ``` ::: :::{dropdown} Файл учебных алгоритмов целиком ```{literalinclude} ../../../../../examples/mail/crypto.py :language: python ``` ::: Запуск: `uv run python -m examples.mail.http.encryption`. ```text encrypted message: 100 found clear subject: Секретная встреча ``` :::: ::::{tab-item} Браузер :sync: browser JavaScript учебной страницы сам шифрует поля и тело перед `fetch` и расшифровывает ответ; браузерная операция только заполняет форму и распознаёт {doc}`три исхода композера `. :::: ::::: :::{warning} Base64 не обеспечивает секретность. Эти обратимые алгоритмы показывают места подключения; в приложении передайте алгоритм с проверенной схемой, управлением ключами и уникальным `nonce`. ::: ## Дальше - {doc}`Соберите HTTP- и браузерные роутеры `. - {doc}`Изучите полный контракт payload crypto `. --- title: Вход в три шага description: Три операции проводят аккаунт от адреса почты до готовой сессии. freshness: false --- Вход не стоит прятать в один метод. После адреса сайт показывает форму пароля, после пароля переводит на второй фактор и только после кода выдаёт сессию. Отдельная операция на каждый шаг сохраняет этот порядок в типах. | Шаг | Запрос или страница | Следующее состояние | |---|---|---| | Адрес | `POST /login/identify` или поле `email` | `PasswordStep` | | Пароль | `POST /login/password` или поле `password` | `OtpStep` | | Код | `POST /login/otp` или поле `code` | `Session` или `Mailbox` | :::::{grid} 1 1 2 2 :class-container: http-exchange :gutter: 2 ::::{grid-item} :class: http-panel http-panel--request **→ Request** · ``POST /login/identify`` ```json {"email": "ada@mail.example"} ``` :::: ::::{grid-item} :class: http-panel http-panel--response **← Response** · ``200 OK`` ```json { "status": "ok", "body": {"login_id": "login:ada@mail.example"} } ``` :::: ::::: :::::{tab-set} :sync-group: transport ::::{tab-item} HTTP :sync: http Каждый HTTP-вызов получает конверт с `status` и `body`. `Payload` снимает внешний конверт, поэтому операция возвращает сразу модель следующего шага. ```{literalinclude} ../../../../../examples/mail/http/login_probe.py :language: python :start-after: "# region docs: http-login-operations" :end-before: "# endregion docs: http-login-operations" ``` Роутер публикует три операции и наследует общие варианты отказа от `LoginService`. ```{literalinclude} ../../../../../examples/mail/http/login_probe.py :language: python :start-after: "# region docs: http-login-router" :end-before: "# endregion docs: http-login-router" ``` В прикладном коде результат предыдущего вызова даёт данные для следующего: ```{literalinclude} ../../../../../examples/mail/http/login_success.py :language: python :start-after: "# region docs: http-login-flow" :end-before: "# endregion docs: http-login-flow" ``` :::{dropdown} Файлы примера целиком ```{literalinclude} ../../../../../examples/mail/http/login_probe.py :language: python ``` ```{literalinclude} ../../../../../examples/mail/http/login_success.py :language: python ``` ::: Запуск: `uv run python -m examples.mail.http.login_success`. ```text session: session-ada ``` :::: ::::{tab-item} Браузер :sync: browser Браузерные операции заполняют по одной форме. После клика правило `when(...)` ждёт признак следующей страницы, а `to=` строит состояние с тем же роутером. После отправки адреса тот же `MailSite` возвращает страницу пароля: ```html
``` ```{literalinclude} ../../../../../examples/mail/browser/login_probe.py :language: python :start-after: "# region docs: browser-login-operations" :end-before: "# endregion docs: browser-login-operations" ``` ```{literalinclude} ../../../../../examples/mail/browser/login_probe.py :language: python :start-after: "# region docs: browser-login-router" :end-before: "# endregion docs: browser-login-router" ``` Цепочка ниже проходит обычный mypy-гейт. Если операция вернёт не тот шаг, присваивание не сойдётся по типу. ```{literalinclude} ../../../../../examples/mail/browser/login_probe.py :language: python :start-after: "# region docs: browser-login-flow" :end-before: "# endregion docs: browser-login-flow" ``` :::{dropdown} Файл примера целиком ```{literalinclude} ../../../../../examples/mail/browser/login_probe.py :language: python ``` ::: :::: ::::: `PasswordStep` и `OtpStep` хранят тот же `LoginPortal`, поэтому вызывающему коду не нужно пересобирать клиент между страницами. HTTP-линия передаёт `login_id`, браузерная сохраняет контекст страницы; обе следуют одному порядку сайта. ## Дальше - {doc}`Разберите все исходы входа `. - {doc}`Вернитесь к карте учебного сайта `. --- title: Чем вход может закончиться description: Шесть ответов входа как модели HTTP, признаки страницы и точные исключения. freshness: false --- Успешный вход проходит через второй фактор. Остальные ответы должны иметь собственные имена: тогда приложение ловит ожидаемый отказ по типу, а не разбирает текст или адрес страницы. | Случай | Что получает вызывающий | |---|---| | Неверный пароль | `WrongPassword` или `WrongPasswordError` | | Аккаунт не найден | `AccountNotFound` или `AccountNotFoundError` | | Аккаунт заблокирован | `AccountBlocked` или `AccountBlockedError` | | Нужен второй фактор | `OtpStep` | | Неверный код | `WrongCode` или `WrongCodeError` | | Нужна капча | `CaptchaRequired` или `CaptchaRequiredError` | :::::{grid} 1 1 2 2 :class-container: http-exchange :gutter: 2 ::::{grid-item} :class: http-panel http-panel--request **→ Request** · ``POST /login/password`` ```json { "login_id": "login:ada@mail.example", "password": "wrong" } ``` :::: ::::{grid-item} :class: http-panel http-panel--response **← Response** · ``200 OK`` ```json { "status": "wrong_password", "body": { "code": "wrong_password", "message": "Password is incorrect" } } ``` :::: ::::: :::::{tab-set} :sync-group: transport ::::{tab-item} HTTP :sync: http В JSON имя варианта повторяется во внешнем `status` и во внутреннем `body.code`. `Const` проверяет оба дискриминатора до создания исключения. Капча приходит с HTTP-статусом `403`, остальные ожидаемые ответы этого сайта остаются внутри `200`. ```{literalinclude} ../../../../../examples/mail/http/login_probe.py :language: python :start-after: "# region docs: http-login-failures" :end-before: "# endregion docs: http-login-failures" ``` | Ответ сайта | Правило | Результат | |---|---|---| | `status = wrong_password` | `Error(200, Json(WrongPasswordResponse), ...)` | `WrongPassword` | | `status = account_not_found` | `Error(200, Json(AccountNotFoundResponse), ...)` | `AccountNotFound` | | `status = account_blocked` | `Error(200, Json(AccountBlockedResponse), ...)` | `AccountBlocked` | | `status = ok`, модель `OtpStep` | успешный ответ `SubmitPassword` | `OtpStep` | | `status = wrong_code` | `Error(200, Json(WrongCodeResponse), ...)` | `WrongCode` | | HTTP `403`, `kind = captcha` | `Error(403, Json(CaptchaRequiredResponse), ...)` | `CaptchaRequired` | :::{dropdown} Файл примера целиком ```{literalinclude} ../../../../../examples/mail/http/login_probe.py :language: python ``` ```{literalinclude} ../../../../../examples/mail/http/login_failures.py :language: python ``` ::: Запуск: `uv run python -m examples.mail.http.login_failures`. ```text wrong password: WrongPassword account not found: AccountNotFound account blocked: AccountBlocked second factor: OtpStep wrong code: WrongCode captcha: CaptchaRequired ``` :::: ::::{tab-item} Браузер :sync: browser Страница сообщает об отказах атрибутами `data-code`. Правила аккаунта принадлежат роутеру, а неверный пароль, капча и неверный код остаются у операций своих шагов. При неверном пароле браузер видит этот фрагмент фактического ответа сайта: ```html

Password is incorrect

``` ```{literalinclude} ../../../../../examples/mail/browser/login_probe.py :language: python :start-after: "# region docs: browser-login-failures" :end-before: "# endregion docs: browser-login-failures" ``` | Признак страницы | Где объявлен | Результат | |---|---|---| | `data-code="wrong_password"` | шаг пароля | `WrongPasswordError` | | `data-code="account_not_found"` | `LoginPortal.errors` | `AccountNotFoundError` | | `data-code="account_blocked"` | `LoginPortal.errors` | `AccountBlockedError` | | поле `code` | исход шага пароля | `OtpStep` | | `data-code="wrong_code"` | шаг кода | `WrongCodeError` | | `data-page="captcha"` | шаг пароля | `CaptchaRequiredError` | :::{dropdown} Файл примера целиком ```{literalinclude} ../../../../../examples/mail/browser/login_probe.py :language: python ``` ```{literalinclude} ../../../../../examples/mail/browser/login_failures.py :language: python ``` ::: Команда `uv run python -m examples.mail.browser.login_failures` запускает Chromium через `PlaywrightDriver`. Перехват запросов передаёт формы тому же `MailSite`, а операция читает DOM-признаки полученной страницы. ```text wrong password: WrongPasswordError account not found: AccountNotFoundError account blocked: AccountBlockedError second factor: OtpStep wrong code: WrongCodeError captcha: CaptchaRequiredError ``` :::: ::::: :::{note} `OtpStep` не является ошибкой. Это ожидаемое продолжение входа, поэтому оно объявлено исходом операции, а не исключением. ::: ## Дальше - {doc}`Сверьте карту учебного сайта `. - {doc}`Вернитесь к входу в три шага `. --- title: Чтение всех писем по частям description: Обойдите один список через offset, cursor или next URL и подгрузите письма кнопкой. freshness: false --- Почта отдаёт по два письма. HTTP-клиент может следовать смещению, курсору или готовой ссылке; браузер видит первую пару в HTML и просит страницу подгрузить вторую. Во всех четырёх случаях результат один: письма `42`, `43`, `44` и `45` в исходном порядке. | Вариант | Адрес следующей порции | Условие остановки | |---|---|---| | HTTP, `Pages.offset` | `offset + len(items)` | достигнут `total` | | HTTP, `Pages.cursor` | `next_cursor` из ответа | курсор равен `null` | | HTTP, `Pages.next_url` | готовый `next_url` | ссылка равна `null` | | Браузер, `each(...)` | кнопка `data-action="more"` | четвёртый элемент появился в коллекции | :::::{grid} 1 1 2 2 :class-container: http-exchange :gutter: 2 ::::{grid-item} :class: http-panel http-panel--request **→ Request** · ``GET /api/v1/threads/status/smart?offset=0&limit=2`` ```text Authorization: Bearer session-1 ``` :::: ::::{grid-item} :class: http-panel http-panel--response **← Response** · ``200 OK`` ```json { "status": "ok", "body": { "items": [ {"id": 42, "sender": "ada@mail.example", "recipient": "reader@mail.example", "subject": "Планы на пятницу"}, {"id": 43, "sender": "grace@mail.example", "recipient": "reader@mail.example", "subject": "Сверка протокола"} ], "total": 4, "next_offset": 2, "next_cursor": "cursor-2", "next_url": "/api/v1/threads/status/smart?offset=2&limit=2" } } ``` :::: ::::: :::::{tab-set} :sync-group: transport ::::{tab-item} HTTP :sync: http Три операции обращаются к одной ручке и разбирают одну модель ответа. Различается только `__pages__`: стратегия называет поля запроса и функцию, которая читает указатель следующей порции. ```{literalinclude} ../../../../../examples/mail/http/messages.py :language: python :start-after: "# region docs: http-message-pages" :end-before: "# endregion docs: http-message-pages" ``` Цикл не попадает в прикладной код. Метод `items()` отправляет каждую следующую операцию с той же авторизацией, разбирает ответ обычным способом и отдаёт сообщения по одному. ```{literalinclude} ../../../../../examples/mail/http/messages.py :language: python :start-after: "# region docs: http-message-flow" :end-before: "# endregion docs: http-message-flow" ``` :::{dropdown} Файл примера целиком ```{literalinclude} ../../../../../examples/mail/http/messages.py :language: python ``` ::: Запуск: `uv run python -m examples.mail.http.messages`. ```text offset: [42, 43, 44, 45] cursor: [42, 43, 44, 45] next URL: [42, 43, 44, 45] ``` :::: ::::{tab-item} Браузер :sync: browser Первая страница входящих содержит два элемента и кнопку: ```html ``` `each(...)` связывает поле карты со всеми видимыми `li`. Сначала операция читает снимок коллекции, затем нажимает кнопку и ждёт элемент с индексом `3`. После этого новый снимок уже содержит обе порции. ```{literalinclude} ../../../../../examples/mail/browser/messages.py :language: python :start-after: "# region docs: browser-message-collection" :end-before: "# endregion docs: browser-message-collection" ``` Вызов открывает `/inbox/` в настоящем Chromium. JavaScript страницы отправляет второй запрос к тому же `MailSite` и добавляет полученные узлы в список. ```{literalinclude} ../../../../../examples/mail/browser/messages.py :language: python :start-after: "# region docs: browser-message-flow" :end-before: "# endregion docs: browser-message-flow" ``` :::{dropdown} Файл примера целиком ```{literalinclude} ../../../../../examples/mail/browser/messages.py :language: python ``` ::: Запуск: `uv run python -m examples.mail.browser.messages`. ```text first batch: [42, 43] after more: [42, 43, 44, 45] ``` :::: ::::: :::{note} `Elements.count()` и `Elements.texts()` читают снимок без ожидания. Когда нужен будущий узел, обращайтесь к `nth(i)`: действие над ним ждёт появления элемента в пределах таймаута. ::: ## Дальше - {doc}`Отправьте письмо и различите три исхода `. - {doc}`Сверьте карту учебного сайта `. --- title: Отправка письма без опасного повтора description: Подпишите POST через HMAC и различите отправку, отклонение и отсутствие подтверждения. freshness: false --- Композер отвечает одним из трёх способов: показывает подтверждение, открывает диалог об отклонённом адресе или не показывает ничего. Последний случай не означает ошибку доставки. Клиент не повторяет письмо, а ищет его в «Отправленных» по получателю и теме. | Ответ сайта | Правило HTTP или признак страницы | Что получает вызывающий | |---|---|---| | `outcome: sent` | успешный JSON или видимый `[role="status"]` | `SendResult` или `Sent` | | `recipient_rejected` | типизированная ошибка или открытый `dialog` | `RecipientRejected` или `Rejected` | | `outcome: silent` | JSON без подтверждения на странице | `silent`, затем результат поиска, или `Silent` | :::::{grid} 1 1 2 2 :class-container: http-exchange :gutter: 2 ::::{grid-item} :class: http-panel http-panel--request **→ Request** · ``POST /api/v1/messages/send`` ```text Authorization: Bearer session-1 Content-Type: application/json X-Mail-Signature: 4a0a5ef967590a6eeec04612bc5a0066c0826b0c3d36af223ab541bc2055c815 {"recipient":"grace@mail.example","subject":"Проверка доставки","body":"Встречаемся в пятницу."} ``` :::: ::::{grid-item} :class: http-panel http-panel--response **← Response** · ``200 OK`` ```json { "status": "ok", "body": {"outcome": "sent", "message_id": 100} } ``` :::: ::::: :::::{tab-set} :sync-group: transport ::::{tab-item} HTTP :sync: http Подпись строится после сериализации тела. `body_digest("sha256")` даёт hex-строку хеша, `hmac_sha256(...)` подписывает её учебным ключом, а `header_output(...)` записывает результат в `X-Mail-Signature`. ```{literalinclude} ../../../../../examples/mail/http/send.py :language: python :start-after: "# region docs: http-send-signature" :end-before: "# endregion docs: http-send-signature" ``` `SendMessage` отличает успешный конверт от отклонённого адреса по `Const`. Поиск в «Отправленных» остаётся отдельной идемпотентной GET-операцией. ```{literalinclude} ../../../../../examples/mail/http/send.py :language: python :start-after: "# region docs: http-send-operations" :end-before: "# endregion docs: http-send-operations" ``` После `silent` код не вызывает `send` второй раз. Он ищет письмо с теми же получателем и темой; найденный идентификатор `101` доказывает, что первый запрос уже сработал. ```{literalinclude} ../../../../../examples/mail/http/send.py :language: python :start-after: "# region docs: http-send-flow" :end-before: "# endregion docs: http-send-flow" ``` :::{dropdown} Файл примера целиком ```{literalinclude} ../../../../../examples/mail/http/send.py :language: python ``` ::: Запуск: `uv run python -m examples.mail.http.send`. ```text sent: 100 rejected: RecipientRejected silent response: silent found in Sent: 101 ``` :::: ::::{tab-item} Браузер :sync: browser Страница `/compose/` содержит форму и два наблюдаемых признака. Третий исход определяется по истечению общего срока ожидания, когда ни подтверждение, ни диалог не появились. ```{literalinclude} ../../../../../examples/mail/site/app.py :language: html :start-after: "" :end-before: "" ``` HMAC здесь вычисляет JavaScript самой учебной страницы перед `fetch`. Браузерная операция отвечает только за поля формы, клик и распознавание результата через `outcomes(...)`. ```{literalinclude} ../../../../../examples/mail/browser/send.py :language: python :start-after: "# region docs: browser-send-operation" :end-before: "# endregion docs: browser-send-operation" ``` Три последовательных вызова идут через настоящий Chromium и один `MailSite`. Перед новым вызовом операция закрывает диалог предыдущего отказа, если он ещё открыт. ```{literalinclude} ../../../../../examples/mail/browser/send.py :language: python :start-after: "# region docs: browser-send-flow" :end-before: "# endregion docs: browser-send-flow" ``` :::{dropdown} Файл примера целиком ```{literalinclude} ../../../../../examples/mail/browser/send.py :language: python ``` ::: Запуск: `uv run python -m examples.mail.browser.send`. ```text sent: Sent rejected: Rejected silent: Silent ``` :::: ::::: :::{warning} Не повторяйте отправку после тишины в интерфейсе: запрос мог завершиться успешно. Сначала найдите письмо в «Отправленных» по устойчивым полям. ::: ## Дальше - {doc}`Зашифруйте поля и всё тело запроса `. - {doc}`Прочитайте все письма по частям `. --- title: Обновление сессии без ручного повтора description: Возьмите токен из HTML и обновляйте сессию по сроку или после отказа сайта. freshness: false --- Сессия живёт дольше одного запроса. Страница входящих уже содержит access-токен, refresh-токен и срок действия. HTTP-клиент читает их как `MailSession`; браузер хранит ту же сессию в cookie контекста. Прикладной код вызывает только защищённую операцию, а вход, обновление и повтор остаются внутри жизненного цикла. | Ситуация | Что видит сайт | Что делает SDK | Результат | |---|---|---|---| | Первый HTTP-запрос | сессии ещё нет | читает токены из HTML | `session-1` | | Срок почти вышел | до `expires_at` меньше запаса | обновляет до запроса | `session-refresh-1` | | Access-токен отвергнут | защищённая ручка отвечает `401` | обновляет и повторяет один раз | `session-refresh-2` | | Две вкладки контекста | общая cookie `mail_session` | проводит один вход под локом | обе вкладки открыты | :::::{grid} 1 1 2 2 :class-container: http-exchange :gutter: 2 ::::{grid-item} :class: http-panel http-panel--request **→ Request** · ``POST /api/v1/session/refresh`` ```json {"refresh_token":"refresh-1"} ``` :::: ::::{grid-item} :class: http-panel http-panel--response **← Response** · ``200 OK`` ```json {"status":"ok","body":{"access_token":"session-refresh-1","refresh_token":"refresh-2","expires_at":"2030-01-01T01:05:00+00:00"}} ``` :::: ::::: :::::{tab-set} :sync-group: transport ::::{tab-item} HTTP :sync: http Три поля одной Pydantic-модели выполняют две задачи. `CSS(...)` извлекает значения из HTML, а `Bearer`, `RefreshToken` и `ExpiresAt` описывают их роль в сессии. Запас в десять секунд не даёт отправить токен, который истечёт во время запроса. ```{literalinclude} ../../../../../examples/mail/http/session.py :language: python :start-after: "# region docs: http-session-model" :end-before: "# endregion docs: http-session-model" ``` При первом защищённом вызове `MailSessionService.acquire` читает страницу входящих. Для обновления сервис передаёт refresh-токен обычной незащищённой операции; access-токен туда не нужен и не создаёт цикл авторизации. ```{literalinclude} ../../../../../examples/mail/http/session.py :language: python :start-after: "# region docs: http-session-service" :end-before: "# endregion docs: http-session-service" ``` Один и тот же вызов `sdk.user.current()` показывает оба пути. Сначала часы пересекают локальный срок, затем сайт забывает ещё живой access-токен и отвечает `401`. ```{literalinclude} ../../../../../examples/mail/http/session.py :language: python :start-after: "# region docs: http-session-flow" :end-before: "# endregion docs: http-session-flow" ``` :::{dropdown} Файл примера целиком ```{literalinclude} ../../../../../examples/mail/http/session.py :language: python ``` ::: Запуск: `uv run python -m examples.mail.http.session`. ```text HTML session: ada@mail.example via session-1 expiry refresh: session-refresh-1 401 refresh: session-refresh-2 refresh requests: 2 ``` :::: ::::{tab-item} Браузер :sync: browser После успешного входа MailSite отдаёт страницу с таким фрагментом: ```html ``` `MailLoginService` проходит уже объявленные три шага и экспортирует состояние контекста. `BrowserLogin` проверяет, что в нём появилась cookie `mail_session`; пустой результат входом не считается. ```{literalinclude} ../../../../../examples/mail/browser/session.py :language: python :start-after: "# region docs: browser-session-login" :end-before: "# endregion docs: browser-session-login" ``` `BrowserSession` создаётся на контекст, а не на вкладку. Два клиента с разными страницами получают один объект сессии: параллельные первые переходы сходятся в один вход, затем обе вкладки используют cookie контекста. ```{literalinclude} ../../../../../examples/mail/browser/session.py :language: python :start-after: "# region docs: browser-session-flow" :end-before: "# endregion docs: browser-session-flow" ``` :::{dropdown} Файл примера целиком ```{literalinclude} ../../../../../examples/mail/browser/session.py :language: python ``` ::: Запуск: `uv run python -m examples.mail.browser.session`. ```text browser logins: 1 tabs in context: 2 session cookie: session-ada ``` :::: ::::: :::{note} У `BrowserSession` один объект на один контекст. Для второго контекста создайте второй объект через `login.session(...)`; общее хранилище может согласовать их ревизии, но не объединяет cookie jars. ::: ## Дальше - {doc}`Прочитайте список писем по частям `. - {doc}`Посмотрите, как сессия появилась после входа `. --- title: Руководство description: Выберите задачу, которую нужно решить в SDK. template: splash freshness: false hero: tagline: От запроса и ответа до сессии, повторов и нескольких сервисов. actions: - text: Быстрый старт link: /start/quickstart/ - text: Учебный SDK link: /tutorial/ type: secondary --- ::::{grid} 1 2 2 3 :gutter: 2 :::{grid-item-card} Отправить запрос :link: /guide/requests/index :link-type: doc Все формы HTTP request. ::: :::{grid-item-card} Разобрать ответ :link: /guide/responses/index :link-type: doc Типизированный успех, ошибки, метаданные ответа, HTML и точное правило выбора response case. ::: :::{grid-item-card} Добавить авторизацию :link: /guide/auth/index :link-type: doc Ключи, login и сессия. ::: :::{grid-item-card} Управлять вызовом :link: /guide/reliability/retry :link-type: doc Повторы, redirects, лимит частоты, middleware и значения приложения на каждой стадии вызова. ::: :::{grid-item-card} Защитить данные :link: /guide/signing :link-type: doc Подпись, crypto и guards. ::: :::{grid-item-card} Собрать SDK :link: /guide/multi-service :link-type: doc Несколько сервисов и транспортов в одном проверяемом корне SDK. ::: :::: ## Карта раздела | Задача | Страница | |---|---| | Обойти все страницы результата | {doc}`Пагинация ` | | Передать контекст устройства или tenant | {doc}`Зависимости запроса ` | | Наблюдать или изменить жизненный цикл вызова | {doc}`Middleware ` | | Описать JSON-RPC или другой конверт | {doc}`Протоколы и конверты ` | | Объявить потоковый API | {doc}`WebSocket ` | | Проверить SDK локально | {doc}`Тестирование SDK ` | ## Дальше - {doc}`Выбрать формат запроса ` - {doc}`Выбрать способ авторизации ` - {doc}`Собрать почтовый SDK целиком ` --- title: Передать API key description: Добавьте ключ в заголовок или параметр запроса без аргумента метода. sources: - eazy_sdk.auth.ApiKeyScheme api_fingerprint: "sha256:da86d1aae404ad80bd9415d1f97b97750188f76e9529cce582c8d9438c0c2911" --- Создайте `ApiKeyScheme` для места, которое выбрал сервер. Пример ниже отправляет ключ в `X-API-Key` и не добавляет его в публичную сигнатуру `get_reports()`. :::::{grid} 1 1 2 2 :class-container: http-exchange :gutter: 2 ::::{grid-item} :class: http-panel http-panel--request **→ Запрос** · ``GET /auth/api-key`` ```json {"headers": {"X-API-Key": "key-demo-123"}} ``` :::: ::::{grid-item} :class: http-panel http-panel--response **← Ответ** · ``200 OK`` ```json {"credential": "key-demo-123"} ``` :::: ::::: ```{literalinclude} ../../../../../../examples/docs/auth_api_key.py :language: python :start-after: "# region docs: auth-api-key" :end-before: "# endregion docs: auth-api-key" ``` `security=REPORTING_KEY` связывает операцию со схемой. `REPORTING_KEY.static(...)` создаёт источник значения, а `Identity` передаёт его роутеру во время вызова. Для параметра запроса замените объявление на `ApiKeyScheme.query("api_key", name="reporting-key")`. Остальной код не меняется. :::{dropdown} Файл целиком ```{literalinclude} ../../../../../../examples/docs/auth_api_key.py :language: python ``` ::: ```text API key: key-demo-123 ``` ## Дальше - {doc}`Собрать требования AND и OR ` - {doc}`Передать Bearer token ` - {doc}`Справочник Auth ` --- title: Передать Basic credentials description: Отправьте логин и пароль через Authorization Basic. sources: - eazy_sdk.auth.BasicScheme api_fingerprint: "sha256:bfc0fb3a56c647871a7bfef8afa5273f6f446a61bb2fe799b74f9207720afa19" --- `BasicScheme` кодирует пару `(login, password)` и ставит результат в `Authorization`. Пароль остаётся данными `Identity`, поэтому вызывающий не повторяет его в каждом методе. :::::{grid} 1 1 2 2 :class-container: http-exchange :gutter: 2 ::::{grid-item} :class: http-panel http-panel--request **→ Запрос** · ``GET /auth/basic`` ```json {"headers": {"Authorization": "Basic YWRhOmNvcnJlY3QtaG9yc2U="}} ``` :::: ::::{grid-item} :class: http-panel http-panel--response **← Ответ** · ``200 OK`` ```json {"credential": "ada"} ``` :::: ::::: ```{literalinclude} ../../../../../../examples/docs/auth_basic.py :language: python :start-after: "# region docs: auth-basic" :end-before: "# endregion docs: auth-basic" ``` Имя `mail-basic` нужно для диагностики. Само значение пары выбирает `BasicScheme`; вручную собирать или кодировать заголовок не требуется. :::{warning} Basic не шифрует пароль. Используйте его только через HTTPS и не выводите заголовок в журнал. ::: :::{dropdown} Файл целиком ```{literalinclude} ../../../../../../examples/docs/auth_basic.py :language: python ``` ::: ```text user: ada ``` ## Дальше - {doc}`Передать Bearer token ` - {doc}`Передать API key ` - {doc}`Собрать требования AND и OR ` --- title: Передать Bearer token description: Добавьте готовый access token в Authorization. sources: - eazy_sdk.auth.BearerScheme api_fingerprint: "sha256:e2fff75c3b7db2776393cf39d440eb20d820453e27b30febe997a2f938a562af" --- Используйте `BearerScheme`, когда приложение уже получило access token и SDK не должен его обновлять. Схема добавляет префикс `Bearer` сама. :::::{grid} 1 1 2 2 :class-container: http-exchange :gutter: 2 ::::{grid-item} :class: http-panel http-panel--request **→ Запрос** · ``GET /auth/bearer`` ```json {"headers": {"Authorization": "Bearer access-1"}} ``` :::: ::::{grid-item} :class: http-panel http-panel--response **← Ответ** · ``200 OK`` ```json {"credential": "access-1"} ``` :::: ::::: ```{literalinclude} ../../../../../../examples/docs/auth_bearer.py :language: python :start-after: "# region docs: auth-bearer" :end-before: "# endregion docs: auth-bearer" ``` Операция объявляет `security=ACCESS_TOKEN`, а `Identity` предоставляет значение этой же схемы. Если источник отсутствует, план вызова завершится до HTTP-запроса. Для входа и автоматического обновления не заменяйте значение в `Identity` вручную. Используйте управляемую сессию из разделов про login и refresh. :::{dropdown} Файл целиком ```{literalinclude} ../../../../../../examples/docs/auth_bearer.py :language: python ``` ::: ```text token: access-1 ``` ## Дальше - {doc}`Передать JWT ` - {doc}`Войти и получить сессию ` - {doc}`Обновить сессию ` --- title: Собрать требования AND и OR description: Потребуйте несколько credentials или выберите доступную альтернативу. sources: - eazy_sdk.auth.all_of - eazy_sdk.auth.any_of api_fingerprint: "sha256:dd2902f6d64a707200682f063ddf2ae21705263a5d3241c73bac74b6b07ff310" --- `all_of(...)` объединяет схемы в одно обязательное требование. `any_of(...)` перечисляет альтернативы и выбирает первую, для которой `Identity` содержит все источники. :::::{grid} 1 1 2 2 :class-container: http-exchange :gutter: 2 ::::{grid-item} :class: http-panel http-panel--request **→ Запрос** · ``GET /auth/combined`` ```json { "headers": { "X-Device-Key": "device-7", "Authorization": "Bearer access-1" } } ``` :::: ::::{grid-item} :class: http-panel http-panel--response **← Ответ** · ``200 OK`` ```json {"credential": "device-7+access-1"} ``` :::: ::::: ```{literalinclude} ../../../../../../examples/docs/auth_combined.py :language: python :start-after: "# region docs: auth-combined" :end-before: "# endregion docs: auth-combined" ``` | Выражение | Условие вызова | |---|---| | `all_of(A, B)` | Доступны одновременно `A` и `B` | | `any_of(A, B)` | Доступна хотя бы одна схема | | `any_of(all_of(A, B), C)` | Доступны `A` с `B` или одна `C` | | `security=None` | Операция отключает требование роутера | В примере у `any_of` одна составная альтернатива, поэтому нужны оба источника. Для варианта «оба или cookie» добавьте вторую схему: `any_of(all_of(DEVICE_KEY, ACCESS_TOKEN), COOKIE)`. :::{dropdown} Файл целиком ```{literalinclude} ../../../../../../examples/docs/auth_combined.py :language: python ``` ::: ```text credentials: device-7+access-1 ``` ## Дальше - {doc}`Передать API key ` - {doc}`Передать Bearer token ` - {doc}`Справочник Auth ` --- title: Передать session cookie description: Добавьте готовое значение cookie через Identity. sources: - eazy_sdk.auth.CookieScheme api_fingerprint: "sha256:8c30742714ec92e41f68d080feddc65e5570997389f3fd1dfa82947a71c3bed4" --- `CookieScheme` подходит, когда значение session cookie уже известно. Имя cookie задаётся в схеме, а значение хранится в `Identity`. :::::{grid} 1 1 2 2 :class-container: http-exchange :gutter: 2 ::::{grid-item} :class: http-panel http-panel--request **→ Запрос** · ``GET /auth/cookie`` ```json {"headers": {"Cookie": "mail_session=session-1"}} ``` :::: ::::{grid-item} :class: http-panel http-panel--response **← Ответ** · ``200 OK`` ```json {"credential": "session-1"} ``` :::: ::::: ```{literalinclude} ../../../../../../examples/docs/auth_cookie.py :language: python :start-after: "# region docs: auth-cookie" :end-before: "# endregion docs: auth-cookie" ``` `CookieScheme("mail_session")` управляет только объявленным значением. Обычные cookies клиента могут существовать рядом, но не удовлетворяют `security=MAIL_SESSION` без источника в `Identity`. Если cookie выдаёт браузер после интерактивного входа, используйте мост браузерной сессии из {doc}`главы о сборке SDK `, а не копируйте заголовок вручную. :::{dropdown} Файл целиком ```{literalinclude} ../../../../../../examples/docs/auth_cookie.py :language: python ``` ::: ```text session: session-1 ``` `CookieScheme` подходит, когда значение куки приносит вызывающий: ключ API, готовый идентификатор сессии из другого места. Сессию, которую сайт ставит и меняет сам, ведёт объявление `Cookies(...)`; на одном сервисе они не сочетаются. ## Дальше - {doc}`Вести сессию сайта на куках ` - {doc}`Войти и получить сессию ` - {doc}`Повторно использовать сессию ` --- title: Сайт ведёт сессию на куках description: Объявите это одной строкой на корне SDK, и куки сайта будут ходить между запросами сами, как в браузере. sources: - eazy_sdk.auth.Cookies - eazy_sdk.cookies.CookieJar - eazy_sdk.cookies.CookieState - eazy_sdk.cookies.StoredCookie - eazy_sdk.identity.Identity api_fingerprint: "sha256:9bd88bfe3cfdd65bc956a74024813db3ebd3476598837539957de805570d8878" --- Вход на обычный сайт занимает несколько шагов. Страница входа ставит одни куки, форма входа другие, переход на почту третьи, и каждый следующий запрос должен нести то, что поставили предыдущие. Браузер делает это сам. Перечислять куки в операциях и носить их от ответа к запросу руками не нужно. Объявите `Cookies` на корне SDK. С этого момента каждая кука, которую поставил сайт, возвращается туда, куда положено: в следующую операцию, в следующий шаг перехода, на другой хост того же сайта. ```{literalinclude} ../../../../../../examples/docs/auth_redirect_login.py :language: python :start-after: "# region docs: redirect-login-root" :end-before: "# endregion docs: redirect-login-root" ``` `required=("sid",)` называет куку, без которой сессии нет. Это единственное место, где SDK упоминает куку сайта по имени. ## Шаги входа Шаги входа остаются обычными операциями. Первый читает исход из адреса перехода, второй открывает страницу почты и достаёт токен из встроенного скрипта. Ни одна операция не объявляет поле для куки. ```{literalinclude} ../../../../../../examples/docs/auth_redirect_login.py :language: python :start-after: "# region docs: redirect-login-steps" :end-before: "# endregion docs: redirect-login-steps" ``` Сервис входа вызывает шаги по порядку. Кука `sid` из ответа на первый шаг уходит во второй запрос без его участия. ```{literalinclude} ../../../../../../examples/docs/auth_redirect_login.py :language: python :start-after: "# region docs: redirect-login-session" :end-before: "# endregion docs: redirect-login-session" ``` Модель сессии держит только то, что SDK кладёт в запрос сам: токен и адрес ящика. Кук в ней нет. На сайте, где вся сессия живёт в куках, модель может не размещать ничего. :::{dropdown} Запускаемый файл целиком ```{literalinclude} ../../../../../../examples/docs/auth_redirect_login.py :language: python ``` ::: ```text folders: Inbox, Sent POST /cgi-bin/auth GET /mailbox/ GET /sdc GET /mailbox/ GET /api/folders GET /api/folders wrong password: fail 1 ``` Вход выполнен один раз: второй вызов `folders()` использует ту же сессию. Отказ входа доходит до вызывающего тем типом, каким его объявила операция. ## Переход, записанный в странице Между вторым и четвёртым запросом видно, чего SDK не объявлял. Учебный сайт отвечает на первое открытие почты страницей-заглушкой с ``, которая отправляет на второй хост. Тот ставит ещё одну куку и возвращает обратно обычным `302`. Почта открывается только после этого. Такой переход клиент выполняет сам, если его включить: `Resilience(max_redirects=3, client_redirects=True)`. Он расходует тот же запас, что и `302`. Куки, поставленные по дороге, доходят до следующего шага. Страница считается заглушкой, только если её не приняла ни одна модель операции. Страницу, которую операция описала, клиент не трогает. ## Чьи это куки Куки принадлежат пользователю, то есть объекту `Identity`, а не клиенту и не соединению. | Ситуация | Что происходит | |---|---| | два `Identity` на одном клиенте | куки не смешиваются | | один `Identity` на двух клиентах | куки общие | | роутеры одного корня | делят куки одного пользователя | | `Identity(cookies=...)` | пользователь начинает с готового набора, например из браузера | Каждая кука хранится со своей областью действия: хост или домен с поддоменами, путь, срок, признак `Secure`. Кука без атрибута `Domain` возвращается только на тот хост, который её поставил. Кука без срока хранится наравне с остальными. ## Между запусками Снимок кук сохраняется вместе с сессией в том же `SessionStore`, под той же ревизией. Новый процесс читает его из хранилища и продолжает без входа. Библиотека не очищает куки сама: ни перед входом, ни после отказа. Кука исчезает, когда её удалил сервер или истёк срок. Так сайт узнаёт устройство при повторном входе и реже просит второй фактор. Чтобы начать с чистого листа, удалите запись пользователя вызовом `store.invalidate(key)` или заведите новый `Identity`. ## Куки, которые выбирает вызывающий Правило одно. Куку, которую поставил сайт, несёт библиотека. Куку, значение которой выбирает вызывающий, объявляют полем операции. | Чьё значение | Как объявить | |---|---| | сайта, из `Set-Cookie` | `Cookies(...)` на корне, имя писать не нужно | | вызывающего: язык, согласие, параметр из документации | поле `Cookie[str]` у операции | Поле `Cookie[str]` перекрывает одноимённую куку сайта только в этом запросе и не сохраняется. `Http.get(..., cookies=False)` выводит операцию из общего порядка: она не получает кук и не сохраняет их. Значение куки как данные читает маркер `FromCookie("act")` в модели ответа. Он нужен, когда значение идёт в тело запроса, например защитный токен формы. ## Что проверяется при объявлении | Объявление | Причина отказа | |---|---| | `CookieScheme` в `security` вместе с `Cookies` | сайт либо ведёт сессию сам, либо получает ключ от вызывающего | | заголовок `Cookie` полем или `Inject` | заголовок строит библиотека | | `cookies=False` на сервисе без `Cookies` | отказываться не от чего | | `Identity(cookies=...)` для сервиса без `Cookies` | куки некуда положить | Вход, после которого сайт не поставил куку из `required`, заканчивается ошибкой проверки сессии: обмен завершился, а сессии нет. ## Дальше - {doc}`Опишите исходы входа по адресу перехода `. - {doc}`Положите токен из сессии в query или заголовок `. - {doc}`Передайте куку, которую выбирает вызывающий `. --- title: Выбрать способ авторизации description: Статические данные, составные требования и управляемая сессия. freshness: false --- Ключ, токен или сессия? Авторизация состоит из схемы в контракте операции и источника данных в `Identity`. Схема определяет место в запросе, а источник отдаёт ключ, пароль, токен или cookie во время вызова. | Что требует сайт | Что использовать | |---|---| | Заголовок или параметр с ключом | {doc}`Передать API key ` | | Логин и пароль в `Authorization` | {doc}`Передать Basic credentials ` | | Готовый access token | {doc}`Передать Bearer token ` | | Готовую session cookie | {doc}`Передать session cookie ` | | JWT без клиентской проверки claims | {doc}`Передать JWT ` | | Несколько обязательных или альтернативных схем | {doc}`Собрать требования AND и OR ` | | Получение, обновление и повторное использование сессии | {doc}`Войти и получить сессию ` | Статическая схема подходит, когда credential уже известен и не меняется внутри SDK. Для входа, обновления по сроку или после `401` используйте управляемую сессию. ## Дальше - {doc}`Передать Bearer token ` - {doc}`Войти и получить сессию ` - {doc}`Справочник API авторизации ` --- title: Передать JWT description: Отправьте готовый JSON Web Token как непрозрачный Bearer credential. sources: - eazy_sdk.auth.BearerScheme api_fingerprint: "sha256:e2fff75c3b7db2776393cf39d440eb20d820453e27b30febe997a2f938a562af" --- HTTP-клиент передаёт JWT как строку с префиксом `Bearer`. Он не декодирует claims и не решает, можно ли доверять подписи токена. :::::{grid} 1 1 2 2 :class-container: http-exchange :gutter: 2 ::::{grid-item} :class: http-panel http-panel--request **→ Запрос** · ``GET /auth/jwt`` ```json {"headers": {"Authorization": "Bearer eyJhbGciOiJIUzI1NiJ9.demo.signature"}} ``` :::: ::::{grid-item} :class: http-panel http-panel--response **← Ответ** · ``200 OK`` ```json {"credential": "jwt"} ``` :::: ::::: ```{literalinclude} ../../../../../../examples/docs/auth_jwt.py :language: python :start-after: "# region docs: auth-jwt" :end-before: "# endregion docs: auth-jwt" ``` `BearerScheme("mail-jwt")` отвечает только за размещение credential. Проверка `issuer`, `audience`, срока действия и подписи принадлежит серверу или доверенному компоненту приложения. Не передавайте JWT в параметре запроса и не выводите его в журнал. Если токен приходит после login и обновляется по refresh token, храните его в модели управляемой сессии. :::{dropdown} Файл целиком ```{literalinclude} ../../../../../../examples/docs/auth_jwt.py :language: python ``` ::: ```text credential kind: jwt ``` ## Дальше - {doc}`Передать Bearer token ` - {doc}`Войти и получить сессию ` - {doc}`Обновить сессию ` --- title: Войти и получить сессию description: Свяжите login-операцию с управляемой Bearer-сессией. sources: - eazy_sdk.auth.session_auth - eazy_sdk.auth.session_scheme - eazy_sdk.auth.Bearer api_fingerprint: "sha256:8e23dfe7e5ea90feea2bf451de76be42c5c677d853ad5aefae951b57b4f3a7e1" --- Управляемая сессия выполняет login перед первым защищённым вызовом. Приложение передаёт `LoginCredentials`, а `LoginService` вызывает обычную объявленную операцию и возвращает `UserSession`. :::::{grid} 1 1 2 2 :class-container: http-exchange :gutter: 2 ::::{grid-item} :class: http-panel http-panel--request **→ Запрос** · ``POST /auth/login`` ```json {"email": "ada@mail.example", "password": "correct-horse"} ``` :::: ::::{grid-item} :class: http-panel http-panel--response **← Ответ** · ``200 OK`` ```json { "access_token": "access-1", "refresh_token": "refresh-1", "expires_at": "2099-01-01T00:00:00Z" } ``` :::: ::::: :::::{tab-set} :sync-group: transport ::::{tab-item} HTTP :sync: http Модель отмечает роль каждого поля сессии. `session_scheme(...)` создаёт схему, которую можно поставить в `security` роутера. ```{literalinclude} ../../../../../../examples/docs/auth_lifecycle.py :language: python :start-after: "# region docs: auth-lifecycle-models" :end-before: "# endregion docs: auth-lifecycle-models" ``` Login и refresh остаются обычными публичными операциями, а защищённый роутер использует `USER_SESSION` по умолчанию. ```{literalinclude} ../../../../../../examples/docs/auth_lifecycle.py :language: python :start-after: "# region docs: auth-lifecycle-operations" :end-before: "# endregion docs: auth-lifecycle-operations" ``` `acquire(...)` получает типизированный `context.sdk`. Корень объявляет обе группы один раз. ```{literalinclude} ../../../../../../examples/docs/auth_lifecycle.py :language: python :start-after: "# region docs: auth-lifecycle-service" :end-before: "# endregion docs: auth-lifecycle-service" ``` Первый защищённый вызов запускает `acquire(...)`, сохраняет результат и повторно планирует операцию уже с `Authorization`. ```{literalinclude} ../../../../../../examples/docs/auth_login.py :language: python :start-after: "# region docs: auth-login-run" :end-before: "# endregion docs: auth-login-run" ``` :::: ::::{tab-item} Браузер :sync: browser Браузерный вход начинается с реальной HTML-формы, а пошаговые операции `BrowserLogin` разобраны в {doc}`главе учебного примера `. ```{literalinclude} ../../../../../../examples/mail/site/app.py :language: html :start-after: "" :end-before: "" ``` :::: ::::: | Ответ login | Правило | Что получает вызывающий | |---|---|---| | `200` с моделью сессии | `acquire(...)` завершён | Результат защищённого метода | | Отображённый `ApiError` | Сессия не сохранена | Типизированное исключение | | Неизвестный или повреждённый ответ | Разбор модели не выполнен | Ошибка протокола | :::{dropdown} Запускаемый файл целиком ```{literalinclude} ../../../../../../examples/docs/auth_login.py :language: python ``` ::: ```text account: user-42 ada@mail.example first request: POST /auth/login ``` `session_auth(...)` остаётся короткой формой для SDK без общей схемы роутера. В корне с несколькими группами используйте `session_scheme(...).configure(...)`, как в этом примере. ## Дальше - {doc}`Обновить сессию ` - {doc}`Повторно использовать сессию ` - {doc}`Исходы многошагового входа ` --- title: Разложить сессию по запросу description: Отметьте на модели сессии, какие поля уходят в query и заголовки, и получите вход и обновление без ручной сборки. sources: - eazy_sdk.auth.Placed - eazy_sdk.auth.session_scheme - eazy_sdk.auth.Bearer api_fingerprint: "sha256:dd79449c2df640d41104d3dc64ba72ec9b7bf1cd73acfa16c7def44b6522f82a" --- Не каждая сессия помещается в один заголовок `Authorization`. Веб-почта передаёт токен страницы и адрес ящика в query каждого вызова, а ключ устройства отправляет заголовком. Все три значения принадлежат одной сессии и появляются после одного входа. Отметьте поля модели сессии маркером `Placed`. Схема, вход на первом защищённом вызове и обновление после `401` строятся из этой модели. ```{literalinclude} ../../../../../../examples/docs/auth_placed.py :language: python :start-after: "# region docs: auth-placed-model" :end-before: "# endregion docs: auth-placed-model" ``` `session_scheme(MailSession)` читает маркеры и возвращает схему. Роутер получает её через `security`, а корень SDK связывает схему с учётными данными и сервисом входа одной строкой: `MAIL_SESSION.configure(credentials=..., service=...)`. Вместо `credentials=` можно передать готовую `session=`, тогда вход не выполняется. | Маркер | Куда уходит значение | Тип поля | |---|---|---| | `Placed.query("token")` | параметр query | строка или `SecretStr` | | `Placed.header("X-Device", prefix="")` | заголовок | строка или `SecretStr` | | `Placed.body("token")` | поле тела запроса | строка или `SecretStr` | `secret=False` снимает скрытие значения в диагностике. Оставьте его только для несекретных полей, таких как адрес ящика. `Bearer()` остаётся коротким написанием для `Placed.header("Authorization", prefix="Bearer ")`. Оба маркера живут на одной модели, если сервису нужны и токен в заголовке, и что-то ещё. Куки маркером не размещают. Куки, которые поставил сайт, библиотека несёт сама, когда корень объявил `Cookies(...)`; об этом рассказывает руководство {doc}`о сессии на куках `. :::{dropdown} Запускаемый файл целиком ```{literalinclude} ../../../../../../examples/docs/auth_placed.py :language: python ``` ::: ```text folders: Inbox, Sent request: token=page-token-42&email=ada%40mail.example | X-Device: device-42 ``` ## Сессия рядом с параметрами запроса Некоторые сайты передают сессию там же, где остальные параметры вызова: в query у чтения и в форме у записи. Запишите оба места на одном поле через `|`: `Placed.body("token") | Placed.query("token")`. Места читаются слева направо, и для каждой операции берётся первое, которое у неё есть. Query и заголовки есть у любого запроса. Тело есть у операции, которая объявила плоские поля тела: `Form[...]` или `JsonField[...]`. В такой операции значение сессии становится ещё одним полем тела в её собственной кодировке, а в query не попадает. Операция без тела получает значение в query. Поля сессии встают в конец тела, после полей операции и значений `Inject`. Если сервер чувствителен к порядку, задайте его целиком через `Wire(order=FieldOrder(body=(...)))`. Тело целиком из модели и тело из `BodyProjection` местом для сессии не считаются. Составное тело (`Part`) не поддерживается: такая операция отклоняется до отправки запроса. ## Когда сессия считается пригодной Сессия с пустым размещаемым значением в запрос не уходит. Пустая строка или `None` в отмеченном поле означают, что отправлять нечего, и SDK выполняет вход заново. Поля без маркера на пригодность не влияют. Срок действия по-прежнему задаёт `ExpiresAt`. ## Что проверяется при объявлении `session_scheme(...)` отклоняет модель сразу, без запроса: | Объявление | Причина отказа | |---|---| | два поля с `Placed.query("token")` | одно место запроса принимает одно поле | | `Placed.query(...)` на словаре или списке | одно место принимает одно значение | | два поля с `Bearer()` | заголовок авторизации один | | только `Placed.body(...)`, а у операции нет плоских полей тела | значение некуда положить; отказ приходит на первом вызове операции | Модель без единого маркера допустима только на сайте, который ведёт сессию на куках. В остальных случаях первый защищённый вызов заканчивается ошибкой настройки: такая сессия ничего не кладёт в запрос. ## Дальше - {doc}`Соберите вход на сайте с куками `. - {doc}`Передайте сохранённую сессию и пропустите вход `. - {doc}`Обновите сессию после отказа `. --- title: Обновить сессию description: Выполните refresh по сроку или после 401 и повторите исходный запрос. sources: - eazy_sdk.auth.session_auth - eazy_sdk.auth.RefreshToken - eazy_sdk.auth.ExpiresAt api_fingerprint: "sha256:cd1125189a3510394c327d07d91106a26e9bba4cd0fe9234a4d4639adc92c2af" --- `RefreshToken()` отмечает значение для обновления, а `ExpiresAt()` позволяет сделать refresh до защищённого запроса. Если сервер всё же отвечает `401`, runtime обновляет сессию и один раз повторяет исходный вызов. :::::{grid} 1 1 2 2 :class-container: http-exchange :gutter: 2 ::::{grid-item} :class: http-panel http-panel--request **→ Запрос** · ``POST /auth/refresh`` ```json {"refresh_token": "refresh-1"} ``` :::: ::::{grid-item} :class: http-panel http-panel--response **← Ответ** · ``200 OK`` ```json { "access_token": "access-2", "refresh_token": "refresh-2", "expires_at": "2099-01-01T00:00:00Z" } ``` :::: ::::: :::::{tab-set} :sync-group: transport ::::{tab-item} HTTP :sync: http `LoginService.refresh(...)` извлекает секрет из текущей модели и вызывает публичную refresh-операцию через тот же корень SDK. ```{literalinclude} ../../../../../../examples/docs/auth_lifecycle.py :language: python :start-after: "# region docs: auth-lifecycle-service" :end-before: "# endregion docs: auth-lifecycle-service" ``` Пример начинает с сохранённого `access-1`. Сервер отклоняет его, после чего runtime вызывает refresh и повторяет `GET /account` с `access-2`. ```{literalinclude} ../../../../../../examples/docs/auth_refresh.py :language: python :start-after: "# region docs: auth-refresh-run" :end-before: "# endregion docs: auth-refresh-run" ``` :::: ::::{tab-item} Браузер :sync: browser Браузер хранит токен и срок в состоянии страницы; обновление одной сессии для нескольких вкладок показано в {doc}`главе о браузерной сессии `. ```{literalinclude} ../../../../../../examples/mail/site/app.py :language: html :start-after: "" :end-before: "" ``` :::: ::::: | Причина refresh | Когда он выполняется | Что происходит с вызовом | |---|---|---| | `ExpiresAt()` считает сессию просроченной | До отправки | Запрос уходит один раз с новой сессией | | Защищённый ответ дал `401` | После первого ответа | Запрос повторяется в пределах `auth_retries` | | Refresh завершился ошибкой | Во время обновления | Старая revision не заменяется | :::{dropdown} Запускаемый файл целиком ```{literalinclude} ../../../../../../examples/docs/auth_refresh.py :language: python ``` ::: ```text account: user-42 GET /account Bearer access-1 POST /auth/refresh GET /account Bearer access-2 ``` ## Дальше - {doc}`Повторно использовать сессию ` - {doc}`Войти и получить сессию ` - {doc}`Настроить повтор HTTP-запроса ` --- title: Повторно использовать сессию description: Передайте готовую модель сессии и пропустите login. sources: - eazy_sdk.auth.session_auth - eazy_sdk.auth.session_scheme api_fingerprint: "sha256:e022a2427979a153e99f78911b5decd15fc860ac67949ae2909d06325e31b3d4" --- Передайте `session=`, если приложение уже загрузило сохранённую модель. Runtime проверит срок, добавит credential в защищённый запрос и не вызовет login без необходимости. :::::{tab-set} :sync-group: transport ::::{tab-item} HTTP :sync: http Фабрика примера принимает ровно один исходный способ: `credentials=` для нового входа или `session=` для продолжения. Оба значения связываются с одной `USER_SESSION`. ```{literalinclude} ../../../../../../examples/docs/auth_session.py :language: python :start-after: "# region docs: auth-session-run" :end-before: "# endregion docs: auth-session-run" ``` Здесь `saved-access` ещё действителен, поэтому сеть видит только защищённый `GET /account`. :::: ::::{tab-item} Браузер :sync: browser `BrowserSession` разделяет состояние входа между вкладками одного контекста и обновляет его через один `BrowserLogin`. ```{literalinclude} ../../../../../../examples/mail/site/app.py :language: html :start-after: "" :end-before: "" ``` ```{literalinclude} ../../../../../../examples/mail/browser/session.py :language: python :start-after: "# region docs: browser-session-flow" :end-before: "# endregion docs: browser-session-flow" ``` :::: ::::: | Начальное состояние | Действие runtime | Первый HTTP-вызов | |---|---|---| | Валидная `session` | Использовать Bearer-поле | Защищённая операция | | Просроченная `session` | Вызвать `refresh(...)` | Refresh-операция | | Только `credentials` | Вызвать `acquire(...)` | Login-операция | | Ни сессии, ни credentials | Остановить планирование | HTTP-вызова нет | Для сохранения между запусками передайте совместимый `SessionStore` и стабильный `identity` в настройку схемы. Новая revision записывается только после успешного login или refresh. :::{dropdown} Запускаемый файл целиком ```{literalinclude} ../../../../../../examples/docs/auth_session.py :language: python ``` ::: ```text account: user-42 ada@mail.example requests: 1 ``` ## Дальше - {doc}`Обновить сессию ` - {doc}`Собрать HTTP- и браузерный SDK ` - {doc}`Хранилище SQLModel ` --- title: Передать зависимость запроса description: Добавьте device, tenant или другое значение приложения без аргумента каждого метода. sources: - eazy_sdk.dependencies draft: false api_fingerprint: "sha256:d16f02296e2945f13e1a01992fb71aab78a13b112aa90eb978b39ddfa83cd3b2" --- Зависимость подходит для значения, которым владеет приложение, а не вызывающий код. Автор SDK объявляет тип и назначение, приложение регистрирует provider, а runtime разрешает значение перед подготовкой запроса. ```{literalinclude} ../../../../../examples/docs/dependencies.py :language: python :start-after: "# region docs: dependencies" :end-before: "# endregion docs: dependencies" ``` `DEVICE` выбирает поле `id` результата и пишет его в объявленный `X-Device-Id`. Поле операции имеет `UNSET` по умолчанию, поэтому provider заполняет его только тогда, когда вызывающий не передал значение сам. `DependencyRegistry` принадлежит `Identity` и обслуживает все роутеры корня. Отсутствующий обязательный provider останавливает вызов до сети. :::{dropdown} Файл целиком ```{literalinclude} ../../../../../examples/docs/dependencies.py :language: python ``` ::: ```text device: device-7 ``` ## Выбрать способ применения | Способ | Когда использовать | |---|---| | `Inject(...)` | Значение целиком занимает новый header, query, cookie или поле тела | | `requires=` с `apply=` | Значение заполняет уже объявленный слот операции | | `requires=` с `BodyProjection` | Значение нужно внутри private wire-документа | ## Выбрать срок кэша | Политика | Когда provider вызывается снова | |---|---| | `NONE` | При каждом обращении к зависимости | | `CLIENT` | После создания нового роутера | | `CALL` | Для нового логического вызова | | `ATTEMPT` | Для каждой попытки, включая retry и redirect | Nonce и timestamp обычно требуют `ATTEMPT`, а неизменяемый device context можно хранить с `CLIENT`. Маркер `secret=True` скрывает значение из диагностики. :::{note} Access token относится к авторизации, captcha к защите, а ключ HMAC к `key_provider`. У этих значений другой жизненный цикл, поэтому не оформляйте их как обычные зависимости. ::: ## Дальше - {doc}`Построить private wire-представление ` - {doc}`Подписать запрос ` - {doc}`Справочник зависимостей ` --- title: Зашифровать payload description: Подключите собственный алгоритм к полям и окончательным байтам HTTP или WebSocket. sources: - eazy_sdk.crypto api_fingerprint: "sha256:1ca897fcf483baa16619a7a70467dc11ebfff0aac70248b32279f8915da8451f" --- Алгоритм принадлежит приложению. Eazy SDK задаёт порядок преобразований, но не поставляет cipher, keyring или KMS-клиент. Алгоритм, ключи, nonce и их ротация остаются в приложении. ```{literalinclude} ../../../../../examples/mail/http/encryption.py :language: python :start-after: "# region docs: mail-encryption-profile" :end-before: "# endregion docs: mail-encryption-profile" ``` Профиль сначала шифрует выбранные поля модели, затем JSON-кодек создаёт байты и `encrypt_encoded(...)` шифрует тело целиком. Входящий ответ проходит обратные стадии. ```{literalinclude} ../../../../../examples/mail/http/encryption.py :language: python :start-after: "# region docs: encrypted-mail-operation" :end-before: "# endregion docs: encrypted-mail-operation" ``` `http_encrypted(...)` объявляет внешний и открытый `Content-Type`. Статусы из `plaintext_statuses` разрешено разбирать без расшифровки; остальные несовпадения закрывают вызов ошибкой. ```{literalinclude} ../../../../../examples/docs/encryption.py :language: python :start-after: "# region docs: encryption-run" :end-before: "# endregion docs: encryption-run" ``` :::{dropdown} Запускаемый файл целиком ```{literalinclude} ../../../../../examples/docs/encryption.py :language: python ``` ::: ```text message: 100 decrypted subject: Секретная встреча ``` ## Порядок стадий ```text исходящий: модель → поля → кодек → байты → подпись → отправка входящий: ответ → байты → кодек → поля → проверка модели ``` Подпись видит ciphertext, если операция использует и шифрование, и signing. Каждая повторная попытка заново выполняет преобразования, поэтому nonce и ciphertext предыдущей попытки не переиспользуются. Для WebSocket укажите `websocket_encrypted(...)`. Шифрование всего frame обычно требует binary frame; text разрешён только для алгоритма, который гарантирует валидный UTF-8, тогда как ping, pong и close остаются управляющими frames и вообще не проходят payload crypto. Typed metadata для key id или nonce объявляется через `CryptoResult` и связывается с конкретным wire-slot. Необъявленное, пропущенное или конфликтующее значение приводит к закрытой ошибке. ## Дальше - {doc}`Проследить байты в учебном примере ` - {doc}`Подписать окончательное тело ` - {doc}`Справочник crypto ` --- title: Добавить middleware description: Оберните логический вызов или отдельную попытку отправки. sources: - eazy_sdk.middleware - eazy_sdk.clients.CallOptions api_fingerprint: "sha256:ea26ac1242b2271c449171232fcf48ed6ababf53e500c056a1eed001e0f310c3" --- Два уровня, две задачи. `CallMiddleware` видит один вызов SDK целиком, включая все его повторы. `AttemptMiddleware` работает с одной подготовкой и отправкой HTTP-запроса. ```{literalinclude} ../../../../../examples/docs/middleware.py :language: python :start-after: "# region docs: middleware" :end-before: "# endregion docs: middleware" ``` `call_next(context)` является одноразовой возможностью. Вызовите её ровно один раз и верните результат либо типизированную ошибку. Порядок нескольких middleware похож на вложенные функции: первый зарегистрированный входит первым и выходит последним. :::{dropdown} Файл целиком ```{literalinclude} ../../../../../examples/docs/middleware.py :language: python ``` ::: ```text ready start:getStatus finish:getStatus ``` ## Выбрать hook попытки | Hook | Допустимый результат | |---|---| | `contribute(context)` | `ValuePatch` до подготовки запроса | | `before_emit(context)` | `Continue`, `Fail` или `None` | | `after_response(context)` | `Continue`, `ReplaceResponse`, `ProposeAction` или `Fail` | | `on_transport_error(context)` | `Continue`, `ProposeAction` или `Fail` | Глобальные middleware задаются через `ClientConfig(hooks=Hooks(...))`. Для одного вызова укажите регистрацию в `CallOptions` метода `send(...)`; остальная конфигурация клиента останется прежней. Captcha оформляется guard, а обновление токена управляемой сессией. Middleware не должно создавать для них второй путь выполнения. ## Дальше - {doc}`Настроить retry ` - {doc}`Обработать защитную проверку ` - {doc}`Справочник middleware ` --- title: Собрать SDK для нескольких сервисов description: Объявите адрес на роутере и измените сборку через bind(). sources: - eazy_sdk.SyncRoot - eazy_sdk.AsyncRoot - eazy_sdk.bind - eazy_sdk.Binding - eazy_sdk.api_group api_fingerprint: "sha256:fdfd3e03461fa2a302105337ecc2cc98c86b0a67936d08086d7e4aa284325cf6" --- Один корень, несколько адресов. Адрес сервиса является частью контракта роутера. Общий клиент может обслуживать разные hosts, сохраняя единые transport policy и состояние handler. ```{literalinclude} ../../../../../examples/docs/multi_service.py :language: python :start-after: "# region docs: multi-service" :end-before: "# endregion docs: multi-service" ``` `BooksApi` наследует адрес клиента. `CardsApi` получает адрес от `BillingService`, а `bind(...)` заменяет его для stage-сборки без изменения декларации API. :::{dropdown} Файл целиком ```{literalinclude} ../../../../../examples/docs/multi_service.py :language: python ``` ::: ```text book: SDK Patterns card: card-42 GET api.mail.example/books/7 POST stage-billing.mail.example/cards ``` ## Порядок выбора адреса | Приоритет | Источник | |---|---| | 1 | Абсолютный URL операции | | 2 | `base_url` из наиболее точного `bind(...)` | | 3 | `base_url` роутера или его миксина | | 4 | `base_url` клиента | Отдельный клиент нужен, когда сервису требуется другой proxy, TLS-профиль, handler, timeout или собственная guard-сессия. Передайте его через `bind(Router, client=...)`; в остальных случаях новый клиент только раздробит cookies, connection pool и управляемое состояние без пользы. Контрактные defaults роутера включают `security`, `signing`, `crypto`, `wire`, `errors` и `base_url`. Операция может заменить правила, а `security=None`, `signing=()` и `crypto=None` явно отключают соответствующее наследование. `Root(client)` не владеет переданным клиентом. `Root.from_handler(...)` создаёт и закрывает его сам, а клиент из `bind(..., client=...)` всегда остаётся во владении сборки. ## Дальше - {doc}`Собрать учебный SDK ` - {doc}`Передать общую Identity ` - {doc}`Выбрать HTTP-клиент ` --- title: Обойти страницы результата description: Объявите стратегию в __pages__ и используйте pages() или items(). sources: - eazy_sdk.pagination draft: false api_fingerprint: "sha256:6e7813bf02a0d0e2213da9125d4a5fc22e1f690c8dbe07307963b8d4f047b592" --- Стратегия пагинации принадлежит классу операции. Обычный вызов возвращает одну страницу, а `pages()` и `items()` строят следующие запросы через неизменяемое значение операции. ```{literalinclude} ../../../../../examples/docs/pagination.py :language: python :start-after: "# region docs: pagination" :end-before: "# endregion docs: pagination" ``` `page=` и `size=` называют поля Python-класса, а не параметры на проводе. `items=` извлекает элементы, `total_pages=` сообщает последнюю страницу. Несовпадающий тип результата или опечатка в имени поля останавливает объявление роутера при импорте. :::{dropdown} Файл целиком ```{literalinclude} ../../../../../examples/docs/pagination.py :language: python ``` ::: ```text page 1: Иск, Отзыв page 2: Решение ``` ## Выбрать стратегию | Стратегия | Поле следующего запроса | Признак окончания | |---|---|---| | `Pages.numbered` | Номер страницы | Общее число страниц, короткая или пустая страница | | `Pages.offset` | Смещение | Общее число элементов, короткая или пустая страница | | `Pages.cursor` | Cursor из ответа | Отсутствующий следующий cursor | | `Pages.next_url` | Готовая ссылка | Отсутствующая ссылка | `items()` разворачивает страницы в один поток. Аргумент `key=` пропускает повторяющиеся элементы и завершает обход, если новая страница не добавила ни одного значения. `max_pages=` ограничивает число запросов, а `options=` применяется к каждой странице. `max_items=` ограничивает число элементов: `items(max_items=5)` отдаёт первые пять и не запрашивает страницы после той, на которой нашёлся пятый. Повторы, пропущенные по `key=`, в счёт не идут. `first()` возвращает первый элемент или `None`, если первая страница пуста, и всегда делает ровно один запрос: `latest = await api.documents.first(case_id=cid)`. Тип элемента берётся из `items=` стратегии: если `items=lambda r: r.items` возвращает `list[Document]`, то `items()` отдаёт `Document`, `first()` возвращает `Document | None`, а `key=` принимает `Document`. Отдельная аннотация в вызывающем коде не нужна. Каждая страница проходит обычные retry, middleware, авторизацию, защиту и подпись. Синхронный роутер возвращает iterator, асинхронный async iterator. ## Дальше - {doc}`Работать с запросом как со значением ` - {doc}`Настроить retry ` - {doc}`Справочник операций ` --- title: Обработать защитную проверку description: Распознайте challenge, получите решение и безопасно повторите запрос. freshness: false --- Guard нужен, когда сайт вместо обычного ответа возвращает защитную проверку. Он распознаёт помеху, вызывает типизированный solver, применяет решение и заново готовит исходный запрос в той же транспортной сессии. ## Выбрать готовую защиту Установите пакет с поддерживаемыми пресетами: ```bash uv add "eazy-sdk-core[presets]" ``` Готовые страницы описывают настройку {doc}`Cloudflare Challenge Pages `, {doc}`Turnstile ` и {doc}`reCAPTCHA `. Пресет устанавливается в корне SDK через `ClientConfig(security=Security.of(guard))`. ## Объявить свой guard Пример сайта требует captcha после отправки пароля. HTTP-клиент решает её cookie, а браузер нажимает кнопку внутри той же операции. :::::{tab-set} :sync-group: transport ::::{tab-item} HTTP :sync: http `detect(...)` возвращает `LoginCaptcha` только для знакомого ответа `403`. `solve(...)` получает `SolveContext` и создаёт решение с cookie через `self.solution(...)`. ```{literalinclude} ../../../../../examples/mail/http/captcha_probe.py :language: python :start-after: "# region docs: http-captcha-guard" :end-before: "# endregion docs: http-captcha-guard" ``` Guard устанавливается один раз в `ClientConfig`. После решения runtime повторяет отклонённый password-запрос в пределах бюджета `max_replays=1`. ```{literalinclude} ../../../../../examples/mail/http/captcha.py :language: python :start-after: "# region docs: http-captcha-flow" :end-before: "# endregion docs: http-captcha-flow" ``` ```{literalinclude} ../../../../../examples/docs/protection.py :language: python :start-after: "# region docs: protection-run" :end-before: "# endregion docs: protection-run" ``` :::: ::::{tab-item} Браузер :sync: browser Браузер видит настоящую страницу проверки: ```{literalinclude} ../../../../../examples/mail/site/app.py :language: html :start-after: "" :end-before: "" ``` `Handle` следит за элементом captcha, нажимает кнопку и возвращает управление исходной `BrowserOperation`. Для вызывающего переход к `OtpStep` остаётся одним методом. ```{literalinclude} ../../../../../examples/mail/browser/captcha.py :language: python :start-after: "# region docs: browser-captcha-operation" :end-before: "# endregion docs: browser-captcha-operation" ``` :::: ::::: | Ответ сайта | Что делает правило | Что получает вызывающий | |---|---|---| | Обычный ответ | `detect(...)` возвращает `None` | Результат операции | | Знакомая помеха | Solver создаёт `GuardSolution` | Результат повторённой операции | | Повреждённая помеха | Detector сообщает ошибку разбора | Типизированную ошибку стадии | | Бюджет исчерпан | Повтор запрещён | `ReplayDeniedError` | :::{dropdown} Запускаемый файл целиком ```{literalinclude} ../../../../../examples/docs/protection.py :language: python ``` ::: ```text challenge: mail-login password requests: 2 clearance cookie: login_clearance=solved result: OtpStep ``` ## Настроить область, хранение и повтор | Атрибут | Назначение | |---|---| | `scope` | Ограничивает правило хостом, операцией или другим `RequestScope` | | `cache="none"` | Решение не покидает текущую попытку | | `cache="call"` | Решение повторно используется только внутри одного вызова | | `cache="session"` | Решение разделяется вызовами одной транспортной сессии | | `replay` | Задаёт доказательство безопасности и число повторов | | `revision` | Отделяет новое правило от ранее сохранённых решений | `headers`, `query` и `body` объявляют назначения, которые разрешено менять решению. Для cookie отдельное разрешение не требуется. Кэш можно очистить методом `client.invalidate_protection(...)` для конкретного guard, имени политики или всей сессии. ## Решать в той же транспортной сессии `context.fetch(...)` загружает вспомогательный ресурс через тот же handler, proxy и cookie jar. Такой запрос обходит guards, middleware, rate limit и повтор, поэтому solver не вызывает сам себя. Каждый полученный `Set-Cookie` остаётся в сессии защищённого вызова. `context.identity` описывает `User-Agent`, объявленный proxy и метку impersonation. Сохранённое решение привязано к этому отпечатку и сбрасывается при смене сетевой личности. :::{warning} Если solver использует отдельный браузер или HTTP-клиент вместо `context.fetch(...)`, он сам должен сохранить тот же proxy, `User-Agent`, TLS-отпечаток и cookies. ::: ## Разобрать ошибку стадии | Ошибка | Стадия | |---|---| | `ProtectionConfigurationError` | Некорректная схема до транспортного вызова | | `ChallengeDetectionError` | Неожиданная ошибка detector | | `ChallengeParseError` | Помеха распознана, но её данные повреждены | | `ChallengeSolveError` | Solver не получил решение | | `ChallengeApplicationError` | Решение нельзя безопасно применить | | `ReplayDeniedError` | Нет доказанного или доступного повтора | Ошибки сохраняют исходное исключение в `__cause__`, но не включают тело ответа, cookies или credentials в собственное сообщение. Низкоуровневые контракты генераторов находятся в `eazy_sdk.protection.advanced`; прикладному SDK обычно достаточно `Guard` или готового пресета. ## Дальше - {doc}`Решить captcha в учебном SDK ` - {doc}`Подключить Cloudflare Challenge Pages ` - {doc}`Справочник защиты ` --- title: Описать протокол с конвертом description: Вынесите method, correlation id и payload в протокол сервиса. sources: - eazy_sdk.protocols.JsonRpc - eazy_sdk.protocols.Envelope - eazy_sdk.protocols.rpc_result draft: false api_fingerprint: "sha256:6c1e22fdb8e6a3fccf20e40d6a1f94c03b2361ac1353e43c48d1edc53457e228" --- Конверт не является сетевым транспортом. Он добавляет к полезной нагрузке method, correlation id и служебные поля. Конверт принадлежит сервису, поэтому объявляется на роутере и применяется ко всем его RPC-операциям. ```{literalinclude} ../../../../../examples/docs/protocols.py :language: python :start-after: "# region docs: protocols" :end-before: "# endregion docs: protocols" ``` `JsonRpc(path="/rpc")` выбирает общий HTTP-путь. `Rpc.method(...)` задаёт имя внутри тела, а поля операции становятся `params`. Публичный вызов остаётся обычным типизированным методом. :::{dropdown} Файл целиком ```{literalinclude} ../../../../../examples/docs/protocols.py :language: python ``` ::: ```text receipt: receipt-42 POST api.mail.example/rpc ``` ## Место конверта в запросе ```text модель → проекция → конверт → кодирование → шифрование байтов → подпись ``` Подпись и шифрование всего payload получают конверт целиком. Значение JSON-RPC `id` по умолчанию сохраняется при retry, чтобы сервер мог распознать дубликат. Для сервиса, который требует новый идентификатор, укажите `id_on_retry="regenerate"`. JSON-RPC обычно сообщает ошибку внутри ответа `200`. `rpc_result(...)`, `rpc_error(...)` и `rpc_error_default(...)` превращают result и error в обычные response cases, поэтому вызывающий получает тот же `ApiError`, что и у REST. Пакетный JSON-RPC вызов не входит в прозрачный runtime. Частичный успех нарушает соответствие одной операции одному типизированному результату, а повтор всего batch больше нельзя считать безопасным только по свойствам отдельных элементов. ## Дальше - {doc}`Объявить WebSocket API ` - {doc}`Подписать конверт ` - {doc}`Справочник моделей и кодеков ` --- title: Ограничить частоту отправки description: Резервируйте каждую фактическую попытку перед transport send. sources: - eazy_sdk.ClientConfig - eazy_sdk.ratelimit_runtime api_fingerprint: "sha256:f1b8c86f47f0406a85262c308e317783c914ff78d9851c9e4f759b6338c9e226" --- Считать нужно отправки. Rate limiter вызывается перед каждой физической отправкой, а не один раз на логический метод. Поэтому retry, redirect и replay защитной проверки получают отдельную reservation. ```{literalinclude} ../../../../../../examples/docs/reliability_rate_limit.py :language: python :start-after: "# region docs: reliability-rate-limit" :end-before: "# endregion docs: reliability-rate-limit" ``` `context.authority` позволяет разделить buckets по сервисам. Также доступны операция, номер и вид попытки. Limiter возвращает задержку, но не отправляет HTTP-запрос самостоятельно. :::{dropdown} Файл целиком ```{literalinclude} ../../../../../../examples/docs/reliability_rate_limit.py :language: python ``` ::: ```text result: limited reservation: api.mail.example attempt=1 ``` Ожидание учитывает cancellation и общий timeout. Повтор после `429` создаёт новую reservation; получение и проверка обязательной protection тоже проходят limiter, когда реализованы обычными scoped API-операциями, и потому не образуют скрытый transport path. ## Дальше - {doc}`Настроить retry ` - {doc}`Обработать защитную проверку ` - {doc}`Добавить middleware ` --- title: Обработать HTTP redirect description: Следуйте Location внутри общего цикла попыток. sources: - eazy_sdk.clients.CallOptions api_fingerprint: "sha256:8e7d84406d37526f806419393dd060ef79e8e4ba539a74ba86b25276692b5ec9" --- Отключите автоматические redirects у transport adapter. Тогда Eazy SDK проверит лимит, область credentials и безопасность повторной отправки перед переходом. ```{literalinclude} ../../../../../../examples/docs/reliability_redirect.py :language: python :start-after: "# region docs: reliability-redirect" :end-before: "# endregion docs: reliability-redirect" ``` :::{dropdown} Файл целиком ```{literalinclude} ../../../../../../examples/docs/reliability_redirect.py :language: python ``` ::: ```text result: redirected GET api.mail.example/redirect GET api.mail.example/final ``` | Статус | Следующий method | Тело | |---|---|---| | `301`, `302` | По redirect policy | Обычно удаляется при переходе на `GET` | | `303` | `GET` | Удаляется | | `307`, `308` | Сохраняется | Требует replay-safe body | Каждый переход повторно применяет scoped middleware, авторизацию, подготовку и подпись. Неожиданный authority не получает credentials автоматически. Исчерпание `max_redirects` приводит к `RedirectLimitError`. `Resilience(max_redirects=N, client_redirects=True)` добавляет к статусам переход, записанный в странице тегом ``. Он выполняется запросом `GET`, расходует тот же запас и срабатывает, только если страницу не приняла ни одна модель операции. Тег внутри `