Прочитать исход из адреса перехода#
Форма входа часто отвечает одним и тем же статусом 302 и сообщает исход адресом перехода:
почта, страница второго фактора, возврат с fail=1 или с errno=25. Тело у таких ответов
одинаковое, различает их только заголовок Location.
Опишите каждый исход моделью и поставьте Location на её поле. Какой исход считать успехом, а
какой отказом, решает операция: модель попадает в success= или в errors=.
Куда ведёт |
Модель |
Что получает вызывающий |
|---|---|---|
|
|
|
|
|
|
|
|
исключение |
|
|
исключение |
# 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 и 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 ведут себя как раньше.