diff --git a/hw1/app.py b/hw1/app.py index 6107b870..b32dbf76 100644 --- a/hw1/app.py +++ b/hw1/app.py @@ -1,4 +1,153 @@ +import json from typing import Any, Awaitable, Callable +from urllib.parse import parse_qs + + +async def send_response( + send: Callable[[dict[str, Any]], Awaitable[None]], + status: int, + body: dict[str, Any], +) -> None: + """Отправляет HTTP ответ клиенту""" + await send( + { + "type": "http.response.start", + "status": status, + "headers": [[b"content-type", b"application/json"]], + } + ) + await send( + { + "type": "http.response.body", + "body": json.dumps(body).encode("utf-8"), + } + ) + + +def is_integer_string(s: str) -> bool: + """Проверяет, является ли строка целым числом""" + if not s: + return False + + if s[0] in ("-", "+"): + return s[1:].isdigit() + + return s.isdigit() + + +def factorial(n: int) -> int: + """Вычисляет факториал числа""" + res = 1 + + for i in range(2, n + 1): + res *= i + + return res + + +def fibonacci(n: int) -> int: + """Возвращает n-ное число Фибоначчи""" + if n == 0: + return 0 + elif n == 1: + return 1 + + a, b = 0, 1 + + for _ in range(2, n + 1): + a, b = b, a + b + + return b + + +async def receive_body(receive: Callable[[], Awaitable[dict[str, Any]]]) -> str: + """Получает тело запроса""" + body = b"" + more = True + + while more: + msg = await receive() + + if msg.get("type") != "http.request": + continue + body += msg.get("body", b"") + more = msg.get("more_body", False) + + return body.decode("utf-8") + + +# обработчики +async def handle_factorial( + scope: dict[str, Any], + send: Callable[[dict[str, Any]], Awaitable[None]], +) -> None: + query = parse_qs(scope.get("query_string", b"").decode("utf-8")) + n_str = query.get("n", [""])[0] + + if not n_str or not is_integer_string(n_str): + await send_response(send, 422, {"error": "Unprocessable"}) + return + + n = int(n_str) + if n < 0: + await send_response(send, 400, {"error": "Bad Request"}) + return + + await send_response(send, 200, {"result": factorial(n)}) + + +async def handle_fibonacci( + scope: dict[str, Any], + send: Callable[[dict[str, Any]], Awaitable[None]], +) -> None: + parts = scope["path"].strip("/").split("/") + + if len(parts) != 2 or parts[0] != "fibonacci": + await send_response(send, 404, {"error": "Not Found"}) + return + + n_str = parts[1] + if not n_str or not is_integer_string(n_str): + await send_response(send, 422, {"error": "Unprocessable"}) + return + + n = int(n_str) + if n < 0: + await send_response(send, 400, {"error": "Bad Request"}) + return + + await send_response(send, 200, {"result": fibonacci(n)}) + + +async def handle_mean( + receive: Callable[[], Awaitable[dict[str, Any]]], + send: Callable[[dict[str, Any]], Awaitable[None]], +) -> None: + body_str = await receive_body(receive) + + if not body_str: + await send_response(send, 422, {"error": "Unprocessable"}) + return + + try: + numbers = json.loads(body_str) + except json.JSONDecodeError: + await send_response(send, 422, {"error": "Unprocessable"}) + return + + if not isinstance(numbers, list): + await send_response(send, 422, {"error": "Unprocessable"}) + return + + if len(numbers) == 0: + await send_response(send, 400, {"error": "Bad Request"}) + return + + if not all(isinstance(x, (int, float)) for x in numbers): + await send_response(send, 422, {"error": "Unprocessable"}) + return + + await send_response(send, 200, {"result": sum(numbers) / len(numbers)}) async def application( @@ -12,8 +161,37 @@ async def application( receive: Корутина для получения сообщений от клиента send: Корутина для отправки сообщений клиенту """ - # TODO: Ваша реализация здесь + if scope["type"] == "lifespan": + while True: + msg = await receive() + if msg["type"] == "lifespan.startup": + await send({"type": "lifespan.startup.complete"}) + elif msg["type"] == "lifespan.shutdown": + await send({"type": "lifespan.shutdown.complete"}) + return + + if scope["type"] != "http": + return + + method = scope["method"] + path = scope["path"] + + # все методы пока что реализованы только для GET + if method != "GET": + await send_response(send, 404, {"error": "Not Found"}) + return + + if path.startswith("/fibonacci/"): + await handle_fibonacci(scope, send) + elif path == "/factorial": + await handle_factorial(scope, send) + elif path == "/mean": + await handle_mean(receive, send) + else: + await send_response(send, 404, {"error": "Not Found"}) + if __name__ == "__main__": import uvicorn + uvicorn.run("app:application", host="0.0.0.0", port=8000, reload=True) diff --git a/hw2/hw/docker-compose.yml b/hw2/hw/docker-compose.yml new file mode 100644 index 00000000..55d25d81 --- /dev/null +++ b/hw2/hw/docker-compose.yml @@ -0,0 +1,28 @@ +services: + local: + build: + context: . + dockerfile: ./docker/Dockerfile.shop_api + container_name: shop_api + ports: + - "8080:8080" + restart: always + + prometheus: + image: prom/prometheus:latest + container_name: prometheus + volumes: + - ./docker/prometheus.yml:/etc/prometheus/prometheus.yml:ro + command: + - "--config.file=/etc/prometheus/prometheus.yml" + - "--storage.tsdb.path=/prometheus" + ports: + - "9090:9090" + restart: always + + grafana: + image: grafana/grafana:latest + container_name: grafana + ports: + - "3000:3000" + restart: always diff --git a/hw2/hw/docker/Dockerfile.shop_api b/hw2/hw/docker/Dockerfile.shop_api new file mode 100644 index 00000000..20079f20 --- /dev/null +++ b/hw2/hw/docker/Dockerfile.shop_api @@ -0,0 +1,15 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY requirements.txt /app/requirements.txt +RUN python -m pip install --upgrade pip +RUN pip install --no-cache-dir -r /app/requirements.txt + +COPY . /app + +ENV PYTHONPATH=/app + +EXPOSE 8080 + +CMD ["uvicorn", "shop_api.main:app", "--host", "0.0.0.0", "--port", "8080"] \ No newline at end of file diff --git a/hw2/hw/docker/prometheus.yml b/hw2/hw/docker/prometheus.yml new file mode 100644 index 00000000..f845dfad --- /dev/null +++ b/hw2/hw/docker/prometheus.yml @@ -0,0 +1,10 @@ +global: + scrape_interval: 10s + evaluation_interval: 10s + +scrape_configs: + - job_name: shop-api + metrics_path: /metrics + static_configs: + - targets: + - shop_api:8080 \ No newline at end of file diff --git a/hw2/hw/requirements.txt b/hw2/hw/requirements.txt index 207dcf5c..263783cd 100644 --- a/hw2/hw/requirements.txt +++ b/hw2/hw/requirements.txt @@ -1,6 +1,7 @@ # Основные зависимости для ASGI приложения fastapi>=0.117.1 uvicorn>=0.24.0 +prometheus-fastapi-instrumentator>=6.1.0 # Зависимости для тестирования pytest>=7.4.0 diff --git a/hw2/hw/shop_api/README.md b/hw2/hw/shop_api/README.md new file mode 100644 index 00000000..b1cdd12c --- /dev/null +++ b/hw2/hw/shop_api/README.md @@ -0,0 +1,67 @@ +# Shop API + +Проект реализует REST API для товаров и корзин, а также WebSocket чат по комнатам. + +--- + +# 🔹 Запустить сервер + +## Локально через PowerShell: +```powershell +$env:PYTHONPATH="${PWD}\hw" +uvicorn shop_api.main:app --reload +``` + +## Через Docker Compose: +```powershell +docker compose up --build +``` +- Сервис будет доступен на http://localhost:8080 +- Prometheus на http://localhost:9090 +- Grafana на http://localhost:3000 + +# 🔹 Тестирование WebSocket чата + +## Через браузер (F12 → Console) +Первый пользователь в комнате test_room +``` +const ws = new WebSocket("ws://127.0.0.1:8000/chat/test_room"); +ws.onopen = () => console.log("Первый юзер подключился"); +ws.onmessage = (event) => console.log(event.data); +``` + +Второй пользователь в той же комнате +``` +const ws2 = new WebSocket("ws://127.0.0.1:8000/chat/test_room"); +ws2.onopen = () => console.log("Второй юзер подключился"); +ws2.onmessage = (event) => console.log(event.data); +``` + +Третий пользователь в другой комнате +``` +const ws3 = new WebSocket("ws://127.0.0.1:8000/chat/wrong_room"); +ws3.onopen = () => console.log("Третий юзер подключился в другую комнату"); +ws3.onmessage = (event) => console.log(event.data); +``` + +Отправка сообщений +``` +ws.send("Всем в комнате test_room привет от первого юзера!"); +ws.send("Друзья, отпишитесь плиз кто и в какой комнате получил мое сообщение?!"); +ws2.send("Раз-два-три. Всем привет от второго юзера! Первый, я из test_room тебя слышу"); +ws3.send("АЛЕ? ЭТО ТРЕТИЙ ЮЗЕР. Я НИЧЕГО НЕ СЛЫШУ. ВЫ ГДЕ. Я В wrong_room"); +ws.send("Второй, а ты не знаешь где третий? Мы с тобой только вдвоем тут."); +ws2.send("Раз-два-три. Без понятия."); +``` + +![Тест 3 юзера и 2 чата](images/websocket_chat_test_3_users_2_rooms.png) + +Мониторинг через Prometheus и Grafana + +Скриншоты дашбордов: + +Prometheus: +![Prometheus](images/prometheus_target_health.png) + +Grafana: +![Grafana](images/grafana_work.png) \ No newline at end of file diff --git a/hw2/hw/shop_api/images/grafana_work.png b/hw2/hw/shop_api/images/grafana_work.png new file mode 100644 index 00000000..1d043377 Binary files /dev/null and b/hw2/hw/shop_api/images/grafana_work.png differ diff --git a/hw2/hw/shop_api/images/prometheus_target_health.png b/hw2/hw/shop_api/images/prometheus_target_health.png new file mode 100644 index 00000000..6d744f0c Binary files /dev/null and b/hw2/hw/shop_api/images/prometheus_target_health.png differ diff --git a/hw2/hw/shop_api/images/websocket_chat_test_3_users_2_rooms.png b/hw2/hw/shop_api/images/websocket_chat_test_3_users_2_rooms.png new file mode 100644 index 00000000..7ac65ef6 Binary files /dev/null and b/hw2/hw/shop_api/images/websocket_chat_test_3_users_2_rooms.png differ diff --git a/hw2/hw/shop_api/main.py b/hw2/hw/shop_api/main.py index f60a8c60..5b52734b 100644 --- a/hw2/hw/shop_api/main.py +++ b/hw2/hw/shop_api/main.py @@ -1,3 +1,297 @@ -from fastapi import FastAPI +from fastapi import ( + FastAPI, + HTTPException, + Query, + Response, + status, + WebSocket, + WebSocketDisconnect, +) +from typing import Optional, Dict, List +from prometheus_fastapi_instrumentator import Instrumentator +import random app = FastAPI(title="Shop API") +Instrumentator().instrument(app).expose(app) + +# Хранение данных в оперативной памяти (было сказано, что так можно без БД) +items_db: dict[int, dict] = {} +carts_db: dict[int, dict] = {} +item_id_seq = 1 +cart_id_seq = 1 + + +# -------------------- ITEM -------------------- + + +@app.post("/item", status_code=status.HTTP_201_CREATED) +def create_item(item: dict, response: Response): + """ + Создает новый товар. Проверяем валидность name и price. + Добавляем в базу и возвращаем с Location заголовком. + """ + global item_id_seq + + if ( + "name" not in item + or not isinstance(item["name"], str) + or not item["name"].strip() + ): + raise HTTPException(status_code=422) + + if ( + "price" not in item + or not isinstance(item["price"], (int, float)) + or item["price"] < 0 + ): + raise HTTPException(status_code=422) + + item_data = { + "id": item_id_seq, + "name": item["name"], + "price": float(item["price"]), + "deleted": False, + } + items_db[item_id_seq] = item_data + response.headers["Location"] = f"/item/{item_id_seq}" + item_id_seq += 1 + + return item_data + + +@app.get("/item/{item_id}") +def get_item(item_id: int): + """Возвращаем товар по ID. 404 если нет или удален""" + item = items_db.get(item_id) + + if not item or item["deleted"]: + raise HTTPException(status_code=404) + + return item + + +@app.get("/item") +def list_items( + offset: int = Query(0, ge=0), + limit: int = Query(10, gt=0), + min_price: Optional[float] = Query(None, ge=0), + max_price: Optional[float] = Query(None, ge=0), + show_deleted: bool = Query(False), +): + """Список товаров с фильтром по цене и пагинацией""" + items = list(items_db.values()) + + if min_price is not None: + items = [i for i in items if i["price"] >= min_price] + + if max_price is not None: + items = [i for i in items if i["price"] <= max_price] + + if not show_deleted: + items = [i for i in items if not i["deleted"]] + + return items[offset : offset + limit] + + +@app.put("/item/{item_id}") +def replace_item(item_id: int, item: dict): + """Полная замена товара по ID""" + if item_id not in items_db or items_db[item_id]["deleted"]: + raise HTTPException(status_code=404) + + if ( + "name" not in item + or not isinstance(item["name"], str) + or not item["name"].strip() + ): + raise HTTPException(status_code=422) + + if ( + "price" not in item + or not isinstance(item["price"], (int, float)) + or item["price"] < 0 + ): + raise HTTPException(status_code=422) + + items_db[item_id]["name"] = item["name"] + items_db[item_id]["price"] = float(item["price"]) + + return items_db[item_id] + + +@app.patch("/item/{item_id}") +def patch_item(item_id: int, item: dict, response: Response): + """Частичное обновление товара. Можно менять только name и price""" + if item_id not in items_db: + raise HTTPException(status_code=404) + + item_data = items_db[item_id] + if item_data["deleted"]: + response.status_code = status.HTTP_304_NOT_MODIFIED + return Response(status_code=304) + + if not item: + return item_data + + allowed_fields = {"name", "price"} + extra_fields = set(item.keys()) - allowed_fields + if extra_fields: + raise HTTPException(status_code=422) + + if "name" in item: + if not isinstance(item["name"], str) or not item["name"].strip(): + raise HTTPException(status_code=422) + item_data["name"] = item["name"] + + if "price" in item: + if not isinstance(item["price"], (int, float)) or item["price"] < 0: + raise HTTPException(status_code=422) + item_data["price"] = float(item["price"]) + + return item_data + + +@app.delete("/item/{item_id}") +def delete_item(item_id: int): + """Помечаем товар как удалённый""" + if item_id not in items_db: + raise HTTPException(status_code=404) + + items_db[item_id]["deleted"] = True + + return {"id": item_id} + + +# -------------------- CART -------------------- + + +@app.post("/cart", status_code=status.HTTP_201_CREATED) +def create_cart(response: Response): + """Создаём новую пустую корзину""" + global cart_id_seq + + cart_data = {"id": cart_id_seq, "items": [], "price": 0.0} + carts_db[cart_id_seq] = cart_data + response.headers["Location"] = f"/cart/{cart_id_seq}" + cart_id_seq += 1 + + return cart_data + + +@app.get("/cart/{cart_id}") +def get_cart(cart_id: int): + """Возвращаем корзину по ID""" + cart = carts_db.get(cart_id) + + if not cart: + raise HTTPException(status_code=404) + + return cart + + +def count_total_quantity(cart): + """Считаем общее количество товаров в корзине""" + return sum(item["quantity"] for item in cart["items"]) + + +@app.get("/cart") +def list_carts( + offset: int = Query(0, ge=0), + limit: int = Query(10, gt=0), + min_price: Optional[float] = Query(None, ge=0), + max_price: Optional[float] = Query(None, ge=0), + min_quantity: Optional[int] = Query(None, ge=0), + max_quantity: Optional[int] = Query(None, ge=0), +): + """Список корзин с фильтром по цене и количеству товаров""" + carts = list(carts_db.values()) + if min_price is not None: + carts = [c for c in carts if c["price"] >= min_price] + + if max_price is not None: + carts = [c for c in carts if c["price"] <= max_price] + + if min_quantity is not None: + carts = [c for c in carts if count_total_quantity(c) >= min_quantity] + + if max_quantity is not None: + carts = [c for c in carts if count_total_quantity(c) <= max_quantity] + + return carts[offset : offset + limit] + + +@app.post("/cart/{cart_id}/add/{item_id}") +def add_item_to_cart(cart_id: int, item_id: int): + """Добавляем товар в корзину. Если уже есть — увеличиваем quantity""" + cart = carts_db.get(cart_id) + + if not cart: + raise HTTPException(status_code=404) + + item = items_db.get(item_id) + + if not item or item["deleted"]: + raise HTTPException(status_code=404) + + for c_item in cart["items"]: + if c_item["id"] == item_id: + c_item["quantity"] += 1 + break + else: + cart["items"].append( + { + "id": item_id, + "name": item["name"], + "quantity": 1, + "available": not item["deleted"], + } + ) + + cart["price"] = sum( + c["quantity"] * items_db[c["id"]]["price"] for c in cart["items"] + ) + + return cart + + +# ---------------- WebSocket чат ---------------- + + +class ConnectionManager: + """Менеджер WebSocket соединений по комнатам""" + + def __init__(self): + self.active_connections: Dict[str, List[WebSocket]] = {} + + async def connect(self, chat_name: str, websocket: WebSocket): + await websocket.accept() + self.active_connections.setdefault(chat_name, []).append(websocket) + + def disconnect(self, chat_name: str, websocket: WebSocket): + if chat_name in self.active_connections: + self.active_connections[chat_name].remove(websocket) + if not self.active_connections[chat_name]: + del self.active_connections[chat_name] + + async def broadcast(self, chat_name: str, message: str): + """Отправляем сообщение всем в комнате""" + for connection in self.active_connections.get(chat_name, []): + await connection.send_text(message) + + +manager = ConnectionManager() + + +@app.websocket("/chat/{chat_name}") +async def chat_endpoint(websocket: WebSocket, chat_name: str): + """WebSocket endpoint чата. Каждому клиенту даётся случайное имя.""" + username = f"user{random.randint(1000, 9999)}" + + await manager.connect(chat_name, websocket) + + try: + while True: + data = await websocket.receive_text() + await manager.broadcast(chat_name, f"{username} :: {data}") + except WebSocketDisconnect: + manager.disconnect(chat_name, websocket) diff --git a/lecture3/project_structure.txt b/lecture3/project_structure.txt new file mode 100644 index 00000000..f17b3ca8 Binary files /dev/null and b/lecture3/project_structure.txt differ