Skip to content

Latest commit

 

History

History
396 lines (262 loc) · 22.4 KB

File metadata and controls

396 lines (262 loc) · 22.4 KB

Практическая работа Модуль 3: «Мини-фреймворк маршрутизации запросов»

Часть 1. Техническое задание

Перед тем как писать код, хороший разработчик внимательно читает ТЗ. Прочитай его целиком, прежде чем переходить к следующей части.


1.1 Описание задачи

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

Это учебная модель, а не реальный сервер — она не работает по сети и не использует настоящую базу данных. Наша задача — воспроизвести внутреннюю механику на объектах, чтобы отработать магические методы Python на знакомом контексте (см. вводную лекцию к модулю).

Система должна уметь:

  • представлять входящий запрос — метод (GET, POST и так далее), путь, заголовки, тело;
  • представлять исходящий ответ — статус-код и тело;
  • регистрировать обработчики для конкретных пар (метод, путь);
  • находить подходящий обработчик для входящего запроса и вызывать его;
  • открывать и корректно закрывать соединение с базой данных при обработке запроса, даже если внутри произошла ошибка;
  • вести историю всех обработанных запросов, по которой можно пройтись в цикле for.

1.2 Бизнес-правила

  1. Заголовки запроса должны быть доступны и как обычный словарь (request.headers["content-type"]), и как атрибут объекта (request.content_type) — второй способ должен работать «сам по себе», без необходимости заранее объявлять каждый возможный заголовок как атрибут класса.
  2. Если заголовок, к которому обратились как к атрибуту, отсутствует — возвращается None, а не выбрасывается исключение.
  3. Ответ считается успешным, если его статус-код лежит в диапазоне [200, 300). Ответ должен уметь сообщать об этом в булевом контексте — то есть должно быть возможно написать if response: вместо if 200 <= response.status_code < 300:.
  4. Нельзя зарегистрировать два обработчика для одной и той же пары (метод, путь) — повторная регистрация должна завершаться понятной ошибкой.
  5. Если для входящего запроса не нашлось подходящего зарегистрированного обработчика — система должна вернуть ответ со статус-кодом 404, а не упасть с исключением.
  6. Соединение с базой данных должно гарантированно закрываться после выполнения запроса — вне зависимости от того, произошла ли внутри ошибка.
  7. Историю обработанных запросов можно обойти в цикле for — там должны быть доступны пары «запрос — ответ» в том порядке, в котором они были обработаны.

1.3 Требования к технологиям

Требование Детали
Язык Python 3.10+
Хранилище данных не требуется (сеть и база данных — имитация в памяти, без файлов)
Интерфейс консоль, точка входа — скрипт с заранее заданными тестовыми запросами (без input())
Сторонние библиотеки не использовать (только стандартная библиотека)

Часть 2. Задание для студента

Здесь описана точная архитектура, которую нужно реализовать. Отступать от неё без согласования с преподавателем нельзя — архитектура специально спроектирована так, чтобы закрепить пройденные темы Модуля 3.


2.1 Структура проекта

mini_framework/
│
├── models/
│   ├── __init__.py
│   ├── request.py                 # класс Request
│   └── response.py                # класс Response
│
├── router/
│   ├── __init__.py
│   ├── route.py                   # класс Route
│   └── router.py                  # класс Router
│
├── db/
│   ├── __init__.py
│   └── database_connection.py     # класс DatabaseConnection
│
├── app/
│   ├── __init__.py
│   ├── request_log.py             # класс RequestLog
│   └── app.py                     # класс App
│
└── main.py                        # точка входа

2.2 Описание классов

📄 models/request.py — класс Request

Представляет входящий запрос.

Атрибуты:

Атрибут Тип Описание
method str HTTP-метод ("GET", "POST" и т. д.)
path str путь запроса, например "/api/users"
headers dict заголовки запроса, ключи приведены к нижнему регистру
body любой тип тело запроса (может быть None)

Методы:

  • __init__(method, path, headers=None, body=None) — сохраняет метод и путь, приводит ключи заголовков к нижнему регистру (если headers не передан — используется пустой словарь), сохраняет тело.

  • __str__ — возвращает строку вида "GET /api/users" (метод и путь через пробел).

  • __repr__ — возвращает техническую строку вида "Request(method='GET', path='/api/users')".

  • __getattr__(name) — вызывается только тогда, когда атрибут не найден обычным способом (то есть не для method, path, headers, body — они лежат в __dict__ и находятся раньше). Должен превратить имя атрибута в имя заголовка (заменить _ на -) и поискать его в self.headers. Если заголовок найден — вернуть его значение, если нет — вернуть None.

    Обрати внимание на важную деталь безопасности: внутри __getattr__ нельзя писать self.headers, если есть риск, что __getattr__ может быть вызван раньше, чем атрибут headers вообще появится в объекте, — это привело бы к бесконечной рекурсии (разобрано в Уроке 11). Безопасный способ — обратиться напрямую к self.__dict__.get("headers", {}), полностью в обход обычного поиска атрибута.


📄 models/response.py — класс Response

Представляет исходящий ответ.

Атрибуты:

Атрибут Тип Описание
status_code int статус-код ответа
body любой тип тело ответа

Методы:

  • __init__(status_code, body=None) — сохраняет оба значения.

  • __bool__ — возвращает True, если 200 <= status_code < 300, иначе False.

  • __str__ — возвращает строку вида "[200 OK] {...тело...}" либо "[404 ERROR] {...тело...}" — используй результат bool(self) (то есть if self:), чтобы определить, писать OK или ERROR, а не дублируй проверку диапазона status_code второй раз.

  • __repr__ — возвращает техническую строку вида "Response(status_code=200, body=...)".


📄 router/route.py — класс Route

Представляет один зарегистрированный маршрут — связку «метод + путь + функция-обработчик».

Атрибуты:

Атрибут Тип Описание
method str HTTP-метод маршрута
path str путь маршрута
handler функция функция-обработчик, принимающая Request и возвращающая Response

Методы:

  • __init__(method, path, handler) — сохраняет все три значения.

  • __call__(request) — делает объект Route вызываемым: вызывает self.handler(request) и возвращает результат. Снаружи это должно позволять писать route(request) вместо route.handler(request).

  • __eq__(other) — два маршрута считаются равными, если у них совпадают method и path (handler в сравнении не участвует). Если other не является объектом Route — верни NotImplemented.

  • __str__ и __repr__ — как обычно, читаемое и техническое представление ("GET /api/users" и "Route(method='GET', path='/api/users')" соответственно).

Обрати внимание: если класс переопределяет __eq__, но не переопределяет __hash__, объект становится нехешируемым (разобрано в Уроке 17). В этой задаче Route никогда не кладётся в set и не используется как ключ словаря, поэтому дополнительно реализовывать __hash__ не требуется — но важно понимать, почему в других ситуациях это могло бы стать проблемой.


📄 router/router.py — класс Router

Хранит все зарегистрированные маршруты и находит подходящий для входящего запроса.

Атрибуты:

Атрибут Тип Описание
_routes list[Route] protected-список зарегистрированных маршрутов

Методы:

  • __init__() — создаёт пустой список _routes.

  • register(method, path, handler) — создаёт объект Route, предварительно проверяя через __eq__ (оператором ==, без ручного сравнения полей), что маршрут с такими же method и path ещё не зарегистрирован. Если уже зарегистрирован — выбрасывает ValueError. Иначе добавляет новый маршрут в _routes.

  • dispatch(request) — ищет в _routes маршрут, у которого method и path совпадают с request.method и request.path. Если нашёлся — вызывает его как функцию (route(request), используя __call__) и возвращает результат. Если не нашёлся — возвращает Response(404, {"error": "Маршрут не найден"}).


📄 db/database_connection.py — класс DatabaseConnection

Имитация соединения с базой данных, реализующая протокол контекстного менеджера.

Атрибуты:

Атрибут Тип Описание
name str условное имя базы данных
is_open bool признак того, что соединение сейчас открыто

Методы:

  • __init__(name) — сохраняет имя, устанавливает is_open = False.

  • __enter__() — устанавливает is_open = True, печатает сообщение об открытии соединения, возвращает self.

  • __exit__(exc_type, exc_val, exc_tb) — устанавливает is_open = False. Если внутри блока with произошло исключение (exc_type is not None) — печатает сообщение о том, что транзакция отменена из-за ошибки; иначе — сообщение об успешном закрытии соединения. В любом случае должен возвращать False, чтобы не подавлять исключение, если оно произошло (это разбиралось на Уроке 15).

  • fetch(query) — печатает сообщение о выполняемом запросе и возвращает словарь-заглушку вида {"query": query, "result": "OK"} — имитацию данных, «прочитанных» из базы.


📄 app/request_log.py — класс RequestLog

Хранит историю всех обработанных пар «запрос — ответ» и позволяет обходить её в цикле for.

Атрибуты:

Атрибут Тип Описание
_entries list[tuple[Request, Response]] protected-список пар «запрос, ответ»
_position int protected-текущая позиция при обходе

Методы:

  • __init__() — создаёт пустой список _entries и _position = 0.

  • add(request, response) — добавляет пару (request, response) в конец _entries.

  • __iter__() — сбрасывает _position в 0 и возвращает self (класс сам выступает и итерируемым объектом, и итератором — тот же подход, что разбирался на Уроке 14 на примере Countdown).

  • __next__() — если _position достиг длины _entries, выбрасывает StopIteration; иначе возвращает текущую пару и увеличивает _position на 1.

Обрати внимание: поскольку __iter__ каждый раз сбрасывает _position, повторные последовательные проходы (for ... in log, выполненные один за другим) будут работать корректно. А вот вложенные одновременные проходы по одному и тому же объекту RequestLog (один цикл for внутри другого, оба — по одному и тому же логу) будут работать некорректно, так как оба цикла делят одну и ту же позицию _position. Для этой задачи такой сценарий не нужен, но важно понимать эту границу применимости — она прямо разбиралась на Уроке 14.


📄 app/app.py — класс App

Связывает маршрутизацию и историю запросов в единую точку входа.

Атрибуты:

Атрибут Тип Описание
router Router маршрутизатор приложения
log RequestLog история обработанных запросов

Методы:

  • __init__() — создаёт Router() и RequestLog().

  • register_route(method, path, handler) — делегирует в self.router.register(...).

  • handle_request(request) — получает ответ через self.router.dispatch(request), добавляет пару в self.log, возвращает ответ.


2.3 Схема связей между классами

main.py
  └── создаёт App
        ├── App.router (Router) хранит список Route
        │     └── Route.__call__ вызывает функцию-обработчик,
        │           которая внутри может открыть DatabaseConnection (with)
        └── App.log (RequestLog) — история пар (Request, Response),
              по которой можно пройтись через for

2.4 Файл main.py

Здесь регистрируются функции-обработчики (обычные функции, не методы класса — они принимают Request и возвращают Response), создаётся несколько тестовых объектов Request, каждый прогоняется через app.handle_request(...), и в конце распечатывается вся история через RequestLog.

# main.py — структура (не финальный код, только скелет)

from models.request import Request
from app.app import App

def get_users(request):
    # проверить request.authorization через __getattr__
    # открыть DatabaseConnection через with, получить данные
    # вернуть Response
    pass

def main():
    app = App()
    app.register_route("GET", "/api/users", get_users)
    # зарегистрировать другие маршруты

    requests = [
        # несколько тестовых объектов Request
    ]

    for req in requests:
        response = app.handle_request(req)
        # напечатать req, response, и результат `if response:`

    # пройтись по app.log и напечатать историю

if __name__ == "__main__":
    main()

Часть 3. Пример использования (примерная реализация main.py)

Конкретная реализация всегда зависит от разработчика.

from models.request import Request
from models.response import Response
from db.database_connection import DatabaseConnection
from app.app import App


def get_users(request):
    if not request.authorization:
        return Response(401, {"error": "Требуется авторизация"})

    with DatabaseConnection("users_db") as db:
        data = db.fetch("SELECT * FROM users")

    return Response(200, data)


def create_order(request):
    if not request.body:
        return Response(400, {"error": "Тело запроса не может быть пустым"})

    with DatabaseConnection("orders_db") as db:
        data = db.fetch(f"INSERT INTO orders VALUES ({request.body})")

    return Response(201, data)


def main():
    app = App()
    app.register_route("GET", "/api/users", get_users)
    app.register_route("POST", "/api/orders", create_order)

    requests = [
        Request("GET", "/api/users", headers={"Authorization": "Bearer abc123"}),
        Request("GET", "/api/users"),
        Request("POST", "/api/orders", body={"item": "Книга", "qty": 2}),
        Request("DELETE", "/api/users/1"),
    ]

    for req in requests:
        print(f"\n>>> {req}")
        response = app.handle_request(req)
        print(response)
        print("Успешно" if response else "Ошибка")

    print("\n=== История запросов ===")
    for req, resp in app.log:
        print(f"{req} -> {resp.status_code}")


if __name__ == "__main__":
    main()

Ожидаемый вывод программы:

>>> GET /api/users
[DB] Соединение с 'users_db' открыто
[DB] Выполняется запрос: SELECT * FROM users
[DB] Соединение с 'users_db' закрыто, транзакция подтверждена
[200 OK] {'query': 'SELECT * FROM users', 'result': 'OK'}
Успешно

>>> GET /api/users
[401 ERROR] {'error': 'Требуется авторизация'}
Ошибка

>>> POST /api/orders
[DB] Соединение с 'orders_db' открыто
[DB] Выполняется запрос: INSERT INTO orders VALUES ({'item': 'Книга', 'qty': 2})
[DB] Соединение с 'orders_db' закрыто, транзакция подтверждена
[201 OK] {'query': "INSERT INTO orders VALUES ({'item': 'Книга', 'qty': 2})", 'result': 'OK'}
Успешно

>>> DELETE /api/users/1
[404 ERROR] {'error': 'Маршрут не найден'}
Ошибка

=== История запросов ===
GET /api/users -> 200
GET /api/users -> 401
POST /api/orders -> 201
DELETE /api/users/1 -> 404

Ориентировочное время выполнения: 3–4 академических часа.