Прочитать исход из адреса перехода#

Форма входа часто отвечает одним и тем же статусом 302 и сообщает исход адресом перехода: почта, страница второго фактора, возврат с fail=1 или с errno=25. Тело у таких ответов одинаковое, различает их только заголовок Location.

Опишите каждый исход моделью и поставьте Location на её поле. Какой исход считать успехом, а какой отказом, решает операция: модель попадает в success= или в errors=.

Куда ведёт Location

Модель

Что получает вызывающий

/inbox/

SignedIn в success=

SignedIn с полным адресом

/cgi-bin/secstep?otp=1

CodeRequired в success=

CodeRequired с адресом шага

/login?errno=25

Banned в errors=

исключение AccountBanned

/login?fail=1

Rejected в errors=

исключение InvalidCredentials

# examples/docs/response_redirect.py
@dataclass(frozen=True, slots=True)
class SignedIn:
    url: Annotated[str, Location(path="/inbox*")]


@dataclass(frozen=True, slots=True)
class CodeRequired:
    url: Annotated[str, Location(path="/cgi-bin/secstep*")]


@dataclass(frozen=True, slots=True)
class Banned:
    errno: Annotated[str, Location.query("errno"), Const("25")]


@dataclass(frozen=True, slots=True)
class Rejected:
    fail: Annotated[str, Location.query("fail")]


class AccountBanned(ApiError[Banned]):
    pass


class InvalidCredentials(ApiError[Rejected]):
    pass


@dataclass(frozen=True, slots=True, kw_only=True)
class SubmitCredentials(HttpOperation[SignedIn | CodeRequired]):
    __http__ = Http.post(
        "/cgi-bin/auth",
        success={302: [SignedIn, CodeRequired]},
        errors={302: [AccountBanned, InvalidCredentials]},
    )

    username: Form[str]
    password: Form[str]


class LoginApi(SyncApi):
    submit = op(SubmitCredentials)


Location(path="/inbox*") на поле делает две вещи сразу. Случай забирает только тот ответ, чей адрес подходит под шаблон, а поле получает этот адрес целиком. Относительный адрес сначала приводится к полному по адресу запроса, поэтому /inbox/ и https://mail.example/inbox/ читаются одним объявлением.

Location.query("fail") кладёт в поле значение параметра. Если параметра в адресе нет, случай уступает ответ другим. Нужное значение задаёт Const("25") рядом, как на любом другом поле.

Файл примера целиком
"""Read the outcome of a call from the address its redirect points to."""

from __future__ import annotations

from dataclasses import dataclass
from typing import Annotated

from eazy_sdk import Http, HttpOperation, SyncApi, op
from eazy_sdk.request import Form
from eazy_sdk.response import ApiError, Const, Location
from examples.docs.redirect_site import MAILBOX, PASSWORD, redirect_client


# docs:example:start
# examples/docs/response_redirect.py
@dataclass(frozen=True, slots=True)
class SignedIn:
    url: Annotated[str, Location(path="/inbox*")]


@dataclass(frozen=True, slots=True)
class CodeRequired:
    url: Annotated[str, Location(path="/cgi-bin/secstep*")]


@dataclass(frozen=True, slots=True)
class Banned:
    errno: Annotated[str, Location.query("errno"), Const("25")]


@dataclass(frozen=True, slots=True)
class Rejected:
    fail: Annotated[str, Location.query("fail")]


class AccountBanned(ApiError[Banned]):
    pass


class InvalidCredentials(ApiError[Rejected]):
    pass


@dataclass(frozen=True, slots=True, kw_only=True)
class SubmitCredentials(HttpOperation[SignedIn | CodeRequired]):
    __http__ = Http.post(
        "/cgi-bin/auth",
        success={302: [SignedIn, CodeRequired]},
        errors={302: [AccountBanned, InvalidCredentials]},
    )

    username: Form[str]
    password: Form[str]


class LoginApi(SyncApi):
    submit = op(SubmitCredentials)


# docs:example:end


def main() -> None:
    attempts = (
        (MAILBOX, PASSWORD),
        ("otp@mail.example", PASSWORD),
        ("banned@mail.example", PASSWORD),
        (MAILBOX, "wrong-password"),
    )
    with redirect_client() as client:
        login = LoginApi(client)
        for username, password in attempts:
            try:
                outcome = login.submit(username=username, password=password)
            except AccountBanned as error:
                print(f"{username}: banned, errno {error.error.errno}")
            except InvalidCredentials as error:
                print(f"{username}: rejected, fail {error.error.fail}")
            else:
                print(f"{username}: {type(outcome).__name__} {outcome.url}")


if __name__ == "__main__":
    main()

Запуск: uv run python examples/docs/response_redirect.py.

ada@mail.example: SignedIn https://mail.example/inbox/
otp@mail.example: CodeRequired https://mail.example/cgi-bin/secstep?otp=1
banned@mail.example: banned, errno 25
ada@mail.example: rejected, fail 1

Части шаблона#

Все части необязательны. Указанные части должны совпасть одновременно.

Часть

С чем сравнивается

Пример

host

имя хоста без порта, регистр не важен

Location(host="*.mail.example")

path

путь как он пришёл, регистр важен

Location(path="/inbox*")

query

значения параметров после декодирования

Location(query={"errno": "25"})

contains

весь адрес, поиск подстроки

Location(contains="captcha")

В строке host и path звёздочка заменяет любую последовательность символов, включая косую черту. Остальные символы читаются буквально. Для сложного случая передайте скомпилированное регулярное выражение: оно должно совпасть с частью целиком.

В query значение ... означает, что параметр есть, каким бы он ни был: Location(query={"fail": ...}). Строка сравнивается с каждым значением параметра, если он повторяется.

Location() без аргументов означает только одно: заголовок в ответе есть.

Условие без модели#

Тот же объект работает как условие when=, когда значение из адреса не нужно. Он сочетается с предикатами из eazy_sdk.response.match через &, | и ~.

Пишите такое условие на Bytes или Text. Представление Empty требует пустое тело, а у перехода оно обычно занято страницей с надписью о переадресации.

Поля с типом, допускающим None#

Поле с типом str | None не выбирает случай. Оно заполняется, когда адрес подходит, и остаётся пустым, когда нет. Так читают необязательные части адреса рядом с обязательным шаблоном. Поле со списком list[str] под Location.query(...) получает все значения повторяющегося параметра.

Модель, у которой все поля читаются из заголовков, не трогает тело ответа. Рядом с Location в такой модели стоят FromHeader("X-Request-Id") и FromCookie("sid"). Пропуск обязательной куки или заголовка считается ошибкой формата: случай уже выбран, а ответ неполон.

Что проверяется до отправки#

Случай на статусе из группы 3xx обязан сказать, какой переход он описывает. Объявление без Location на модели и без when= забрало бы любой переход, поэтому SDK отклоняет его при первом вызове операции, до запроса. Если подходит любой адрес, напишите это явно: when=Location().

Статус, на который операция объявила случай, читает операция. Клиент не следует такому переходу, каким бы ни был лимит max_redirects. Переход с адресом, который не описал ни один случай, заканчивается UnexpectedResponseError. Операции без случаев на 3xx ведут себя как раньше.

Дальше#