Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
180 changes: 179 additions & 1 deletion hw1/app.py
Original file line number Diff line number Diff line change
@@ -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(
Expand All @@ -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)
28 changes: 28 additions & 0 deletions hw2/hw/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -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
15 changes: 15 additions & 0 deletions hw2/hw/docker/Dockerfile.shop_api
Original file line number Diff line number Diff line change
@@ -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"]
10 changes: 10 additions & 0 deletions hw2/hw/docker/prometheus.yml
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions hw2/hw/requirements.txt
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Основные зависимости для ASGI приложения
fastapi>=0.117.1
uvicorn>=0.24.0
prometheus-fastapi-instrumentator>=6.1.0

# Зависимости для тестирования
pytest>=7.4.0
Expand Down
67 changes: 67 additions & 0 deletions hw2/hw/shop_api/README.md
Original file line number Diff line number Diff line change
@@ -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)
Binary file added hw2/hw/shop_api/images/grafana_work.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading