Разложить сессию по запросу#

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

Отметьте поля модели сессии маркером Placed. Схема, вход на первом защищённом вызове и обновление после 401 строятся из этой модели.

# examples/docs/auth_placed.py
class MailSession(BaseModel):
    token: Annotated[SecretStr, Placed.query("token")]
    email: Annotated[str, Placed.query("email", secret=False)]
    device: Annotated[SecretStr, Placed.header("X-Device")]


MAIL_SESSION = session_scheme(MailSession, name="mail-session")


@dataclass(frozen=True, slots=True, kw_only=True)
class ListFolders(HttpOperation[Folders]):
    __http__ = Http.get("/api/devices")


class MailApi(AsyncApi):
    security = MAIL_SESSION

    folders = op(ListFolders)


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(...); об этом рассказывает руководство о сессии на куках.

Запускаемый файл целиком
"""Place one session into the query and a header of every protected request."""

from __future__ import annotations

import asyncio
from dataclasses import dataclass
from typing import Annotated, Any, Self

import httpx
from pydantic import BaseModel, SecretStr

from eazy_sdk import AsyncApi, AsyncRoot, Http, HttpOperation, Identity, api_group, op
from eazy_sdk.auth import AuthContext, Placed, session_scheme
from eazy_sdk.handlers.httpx import AsyncHttpxHandler
from examples.docs.redirect_site import (
    BASE_URL,
    DEVICE_KEY,
    MAILBOX,
    PAGE_TOKEN,
    PASSWORD,
    RedirectSite,
)


class MailLogin(BaseModel):
    username: str
    password: SecretStr


class Folders(BaseModel):
    folders: list[str]


# region docs: auth-placed-model
# examples/docs/auth_placed.py
class MailSession(BaseModel):
    token: Annotated[SecretStr, Placed.query("token")]
    email: Annotated[str, Placed.query("email", secret=False)]
    device: Annotated[SecretStr, Placed.header("X-Device")]


MAIL_SESSION = session_scheme(MailSession, name="mail-session")


@dataclass(frozen=True, slots=True, kw_only=True)
class ListFolders(HttpOperation[Folders]):
    __http__ = Http.get("/api/devices")


class MailApi(AsyncApi):
    security = MAIL_SESSION

    folders = op(ListFolders)


# endregion docs: auth-placed-model


class MailLoginService:
    """Stands in for a real login: the guide on cookie sessions declares its steps."""

    async def acquire(self, credentials: MailLogin, context: AuthContext[Any]) -> MailSession:
        return MailSession(
            token=SecretStr(PAGE_TOKEN),
            email=credentials.username,
            device=SecretStr(DEVICE_KEY),
        )


class MailSdk(AsyncRoot):
    mail = api_group(MailApi)

    @classmethod
    def open(cls, site: RedirectSite, credentials: MailLogin) -> Self:
        auth = MAIL_SESSION.configure(credentials=credentials, service=MailLoginService())
        raw = httpx.AsyncClient(transport=httpx.MockTransport(site), headers={}, cookies={})
        return cls.from_handler(
            handler=AsyncHttpxHandler(raw, owns_client=True),
            base_url=BASE_URL,
            identity=Identity(auth=(auth,)),
        )


@dataclass(slots=True)
class RecordingSite(RedirectSite):
    """The teaching site, remembering what the protected request carried."""

    seen: str = ""

    def __call__(self, request: httpx.Request) -> httpx.Response:
        if request.url.path == "/api/devices":
            self.seen = f"{request.url.query.decode()} | X-Device: {request.headers['x-device']}"
        return RedirectSite.__call__(self, request)


async def main() -> None:
    site = RecordingSite()
    credentials = MailLogin(username=MAILBOX, password=SecretStr(PASSWORD))
    async with MailSdk.open(site, credentials) as sdk:
        folders = await sdk.mail.folders()

    print(f"folders: {', '.join(folders.folders)}")
    print(f"request: {site.seen}")


if __name__ == "__main__":
    asyncio.run(main())
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(...), а у операции нет плоских полей тела

значение некуда положить; отказ приходит на первом вызове операции

Модель без единого маркера допустима только на сайте, который ведёт сессию на куках. В остальных случаях первый защищённый вызов заканчивается ошибкой настройки: такая сессия ничего не кладёт в запрос.

Дальше#