diff --git a/hw1/app.py b/hw1/app.py index 6107b870..94d9e8ee 100644 --- a/hw1/app.py +++ b/hw1/app.py @@ -1,5 +1,14 @@ from typing import Any, Awaitable, Callable +import json +from http import HTTPStatus +from router import Router, HTTPError, HTTPResponse, build_error_response +from handlers import handle_factorial, handle_fibonacci, handle_mean +router = Router() + +router.add_route('GET', '/factorial', handle_factorial) +router.add_route('GET', '/fibonacci/{n}', handle_fibonacci) +router.add_route('GET', '/mean', handle_mean) async def application( scope: dict[str, Any], @@ -7,13 +16,100 @@ async def application( send: Callable[[dict[str, Any]], Awaitable[None]], ): """ + ASGI приложение с использованием шаблонного метода для роутинга Args: scope: Словарь с информацией о запросе receive: Корутина для получения сообщений от клиента send: Корутина для отправки сообщений клиенту """ - # TODO: Ваша реализация здесь + if scope["type"] == "lifespan": + while True: + message = await receive() + if message["type"] == "lifespan.startup": + await send({"type": "lifespan.startup.complete"}) + elif message["type"] == "lifespan.shutdown": + await send({"type": "lifespan.shutdown.complete"}) + return + + elif scope["type"] == "http": + await handle_http_request(scope, receive, send) + + +async def handle_http_request( + scope: dict[str, Any], + receive: Callable[[], Awaitable[dict[str, Any]]], + send: Callable[[dict[str, Any]], Awaitable[None]], +): + """Обработка HTTP запросов""" + path = scope["path"] + method = scope["method"] + query_string = scope.get("query_string", b"").decode() + + response = HTTPResponse(status=HTTPStatus.OK, body={ 'result': 'None'}) + try: + body = await parse_request_body(scope, receive) + + handler, context = await router.route(method, path, query_string, body) + + response: HTTPResponse = await handler(context) + + except HTTPError as e: + response = build_error_response(e.status, e.error, e.message) + except Exception as e: + response = build_error_response(HTTPStatus.INTERNAL_SERVER_ERROR, 'Internal Server Error', str(e)) + finally: + await send_response(send, response) + + +async def send_response(send: Callable, response: HTTPResponse): + """Отправка ответов""" + response_body = json.dumps(response['body']).encode("utf-8") + + await send({ + 'type': 'http.response.start', + 'status': response['status'], + 'headers': [ + (b'content-type', b'application/json'), + (b'content-length', str(len(response_body)).encode()) + ], + }) + + await send({ + 'type': 'http.response.body', + 'body': response_body, + }) + +async def parse_request_body(scope: dict[str, Any], receive: Callable) -> Any: + """Загрузка тела запроса""" + headers = dict(scope.get("headers", [])) + content_length = headers.get(b"content-length") + + if content_length: + try: + content_length = int(content_length.decode()) + except (ValueError, AttributeError): + content_length = 0 + else: + content_length = 0 + + if content_length <= 0: + return None + + body = b"" + more_body = True + while more_body: + message = await receive() + body += message.get("body", b"") + more_body = message.get("more_body", False) + + if not body: + return None + + try: + return json.loads(body.decode("utf-8")) + except json.JSONDecodeError: + raise HTTPError(HTTPStatus.UNPROCESSABLE_ENTITY, "Unprocessable Entity", "Invalid JSON format") if __name__ == "__main__": import uvicorn - uvicorn.run("app:application", host="0.0.0.0", port=8000, reload=True) + uvicorn.run("app:application", host="0.0.0.0", port=8000, reload=True) \ No newline at end of file diff --git a/hw1/handlers.py b/hw1/handlers.py new file mode 100644 index 00000000..cb0c37ae --- /dev/null +++ b/hw1/handlers.py @@ -0,0 +1,78 @@ +from structures import HTTPError, HTTPResponse +from http import HTTPStatus +import math + +async def handle_factorial(context: dict) -> HTTPResponse: + """Обработка факториала""" + query_params = context['query_params'] + + if 'n' not in query_params: + raise HTTPError(HTTPStatus.UNPROCESSABLE_ENTITY, "Unprocessable Entity", "Parameter 'n' is required") + + try: + n = int(query_params['n']) + except ValueError: + raise HTTPError(HTTPStatus.UNPROCESSABLE_ENTITY, "Unprocessable Entity", "Parameter 'n' must be an integer") + + if n < 0: + raise HTTPError(HTTPStatus.BAD_REQUEST, "Bad Request", "Parameter 'n' must be non-negative") + + try: + result = math.factorial(n) + return { + 'status': HTTPStatus.OK, + 'body': {'result': result} + } + except OverflowError: + raise HTTPError(HTTPStatus.BAD_REQUEST, "Bad Request", "Parameter 'n' is too large") + + +async def handle_fibonacci(context: dict) -> HTTPResponse: + """Обработка чисел Фибоначчи""" + path_params = context['path_params'] + + try: + n = int(path_params['n']) + except (KeyError, ValueError): + raise HTTPError(HTTPStatus.UNPROCESSABLE_ENTITY, "Unprocessable Entity", "Invalid number parameter") + + if n < 0: + raise HTTPError(HTTPStatus.BAD_REQUEST, "Bad Request", "Number must be non-negative") + + if n in [0, 1]: + result = n + else: + a, b = 0, 1 + for _ in range(2, n + 1): + a, b = b, a + b + result = b + + return { + 'status': HTTPStatus.OK, + 'body': {'result': result} + } + + +async def handle_mean(context: dict) -> HTTPResponse: + """Обработка среднего значения""" + body_data = context['body'] + + if body_data is None: + raise HTTPError(HTTPStatus.UNPROCESSABLE_ENTITY, "Unprocessable Entity", "JSON body is required") + + if not isinstance(body_data, list): + raise HTTPError(HTTPStatus.UNPROCESSABLE_ENTITY, "Unprocessable Entity", "Expected a list of numbers") + + if not body_data: + raise HTTPError(HTTPStatus.BAD_REQUEST, "Bad Request", "At least one number is required") + + try: + numbers = [float(num) for num in body_data] + except (TypeError, ValueError): + raise HTTPError(HTTPStatus.UNPROCESSABLE_ENTITY, "Unprocessable Entity", "All values must be valid numbers") + + mean = sum(numbers) / len(numbers) + return { + 'status': HTTPStatus.OK, + 'body': {'result': mean} + } diff --git a/hw1/router.py b/hw1/router.py new file mode 100644 index 00000000..3befb485 --- /dev/null +++ b/hw1/router.py @@ -0,0 +1,77 @@ +from typing import Any, Callable, Optional, Dict, TypedDict +import re +from http import HTTPStatus +from structures import HTTPResponse, HTTPError + +def build_error_response(status: int, error: str, message: str) -> HTTPResponse: + response: HTTPResponse = { + 'status': status, + 'body': { + 'error': error, + 'message': message + } + } + return response + +class Router: + + def __init__(self): + self.routes = [] + self.handlers: Dict[str, Callable] = {} + + def add_route(self, method: str, path: str, handler: Callable): + """Добавляет маршрут""" + pattern = self._path_to_pattern(path) + self.routes.append({ + 'method': method.upper(), + 'pattern': pattern, + 'handler': handler + }) + + @staticmethod + def _path_to_pattern(path: str) -> re.Pattern: + """Конвертирует путь с параметрами в regex паттерн""" + pattern = re.sub(r'\{(\w+)}', r'(?P<\1>[^/]+)', path) + return re.compile(f'^{pattern}$') + + async def route(self, method: str, path: str, query_string: str, body: Optional[Any]) -> tuple[Callable, dict]: + method = method.upper() + + route_match = self._match_route(method, path) + if not route_match: + raise HTTPError(HTTPStatus.NOT_FOUND, "Not Found", "Endpoint not found") + + handler, path_params = route_match + + query_params = self._parse_query_string(query_string) + + request_context = { + 'path_params': path_params, + 'query_params': query_params, + 'body': body + } + + return handler, request_context + + def _match_route(self, method: str, path: str) -> Optional[tuple[Callable, dict]]: + """Ищет подходящий маршрут и возвращает handler""" + for route in self.routes: + if route['method'] != method: + continue + + match = route['pattern'].match(path) + if match: + return route['handler'], match.groupdict() + + return None + + @staticmethod + def _parse_query_string(query_string: str) -> dict: + """Парсит query string в словарь""" + params = {} + if query_string: + for pair in query_string.split('&'): + if '=' in pair: + key, value = pair.split('=', 1) + params[key] = value + return params \ No newline at end of file diff --git a/hw1/structures.py b/hw1/structures.py new file mode 100644 index 00000000..1db30d42 --- /dev/null +++ b/hw1/structures.py @@ -0,0 +1,15 @@ +from typing import TypedDict + +class HTTPResponse(TypedDict): + """Типизированный ответ обработчика""" + status: int + body: dict + +class HTTPError(Exception): + """Кастомное исключение для HTTP ошибок""" + + def __init__(self, status: int, error: str, message: str): + self.status = status + self.error = error + self.message = message + super().__init__(f"{error}: {message}") \ No newline at end of file diff --git a/hw2/__init__.py b/hw2/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hw2/hw/shop_api/chat/__init__.py b/hw2/hw/shop_api/chat/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hw2/hw/shop_api/chat/websocket.py b/hw2/hw/shop_api/chat/websocket.py new file mode 100644 index 00000000..5d68e9ed --- /dev/null +++ b/hw2/hw/shop_api/chat/websocket.py @@ -0,0 +1,93 @@ +import json +import uuid +from fastapi import APIRouter, WebSocket, WebSocketDisconnect +from typing import Dict, List +import asyncio + + +class ConnectionManager: + def __init__(self): + self.active_connections: Dict[str, List[WebSocket]] = {} + self.usernames: Dict[WebSocket, str] = {} + + async def connect(self, websocket: WebSocket, chat_name: str): + await websocket.accept() + + if chat_name not in self.active_connections: + self.active_connections[chat_name] = [] + + self.active_connections[chat_name].append(websocket) + + username = f"User_{uuid.uuid4().hex[:8]}" + self.usernames[websocket] = username + + await self.broadcast_message( + chat_name, + f"🟢 {username} :: присоединился к чату", + exclude_websocket=websocket + ) + + await websocket.send_text(f"🤖 Добро пожаловать в чат '{chat_name}'! Ваше имя: {username}") + + def disconnect(self, websocket: WebSocket, chat_name: str): + if chat_name in self.active_connections: + if websocket in self.active_connections[chat_name]: + self.active_connections[chat_name].remove(websocket) + + if not self.active_connections[chat_name]: + del self.active_connections[chat_name] + + if websocket in self.usernames: + username = self.usernames[websocket] + del self.usernames[websocket] + + if chat_name in self.active_connections: + self.broadcast_message_sync( + chat_name, + f"🔴 {username} :: покинул чат", + exclude_websocket=websocket + ) + + async def broadcast_message(self, chat_name: str, message: str, exclude_websocket: WebSocket = None): + if chat_name in self.active_connections: + for connection in self.active_connections[chat_name]: + if connection != exclude_websocket: + try: + await connection.send_text(message) + except: + self.disconnect(connection, chat_name) + + def broadcast_message_sync(self, chat_name: str, message: str, exclude_websocket: WebSocket = None): + """Синхронная версия для использования в disconnect""" + if chat_name in self.active_connections: + disconnected = [] + for connection in self.active_connections[chat_name]: + if connection != exclude_websocket: + try: + asyncio.create_task(connection.send_text(message)) + except: + disconnected.append(connection) + + for connection in disconnected: + self.disconnect(connection, chat_name) + + async def handle_message(self, websocket: WebSocket, chat_name: str, message: str): + username = self.usernames.get(websocket, "Unknown") + formatted_message = f"{username} :: {message}" + await self.broadcast_message(chat_name, formatted_message) + + +manager = ConnectionManager() +chat_router = APIRouter() + + +@chat_router.websocket("/chat/{chat_name}") +async def websocket_endpoint(websocket: WebSocket, chat_name: str): + await manager.connect(websocket, chat_name) + + try: + while True: + data = await websocket.receive_text() + await manager.handle_message(websocket, chat_name, data) + except WebSocketDisconnect: + manager.disconnect(websocket, chat_name) \ No newline at end of file diff --git a/hw2/hw/shop_api/main.py b/hw2/hw/shop_api/main.py index f60a8c60..a96d51a9 100644 --- a/hw2/hw/shop_api/main.py +++ b/hw2/hw/shop_api/main.py @@ -1,3 +1,13 @@ from fastapi import FastAPI +from .routes import items, carts +from .chat.websocket import chat_router app = FastAPI(title="Shop API") + +app.include_router(items.router, tags=["items"]) +app.include_router(carts.router, tags=["carts"]) +app.include_router(chat_router, tags=["chat"]) + +@app.get("/") +async def root(): + return {"message": "Shop API is running"} \ No newline at end of file diff --git a/hw2/hw/shop_api/models.py b/hw2/hw/shop_api/models.py new file mode 100644 index 00000000..263c94e1 --- /dev/null +++ b/hw2/hw/shop_api/models.py @@ -0,0 +1,32 @@ +from pydantic import BaseModel, Field, ConfigDict +from typing import List, Optional + +class ItemBase(BaseModel): + name: str = Field(..., min_length=1, description="Наименование товара", examples=["Молоко \"Бурёнка\" 1л"]) + price: float = Field(..., gt=0, description="Цена товара", examples=[159.99]) + +class ItemCreate(ItemBase): + pass + +class ItemUpdate(BaseModel): + model_config = ConfigDict(extra='forbid') + name: Optional[str] = Field(None, min_length=1, description="Новое наименование товара", examples=["Молоко \"Новое\" 1л"]) + price: Optional[float] = Field(None, gt=0, description="Новая цена товара", examples=[169.99]) + +class Item(ItemBase): + id: int = Field(..., description="Уникальный идентификатор товара", examples=[321]) + deleted: bool = Field(False, description="Товар удален (soft delete)") + +class CartItem(BaseModel): + id: int = Field(..., gt=0, description="ID товара", examples=[1]) + name: str = Field(..., min_length=1, description="Название товара", examples=["Туалетная бумага \"Поцелуй\", рулон"]) + quantity: int = Field(..., ge=1, description="Количество товара в корзине", examples=[3]) + available: bool = Field(..., description="Товар доступен для заказа") + +class Cart(BaseModel): + id: int = Field(..., description="Уникальный идентификатор корзины", examples=[123]) + items: List[CartItem] = Field(..., description="Список товаров в корзине") + price: float = Field(..., description="Общая сумма заказа", examples=[234.4]) + +class CartCreateResponse(BaseModel): + id: int = Field(..., description="Идентификатор созданной корзины", examples=[123]) \ No newline at end of file diff --git a/hw2/hw/shop_api/routes/carts.py b/hw2/hw/shop_api/routes/carts.py new file mode 100644 index 00000000..6ad2a0ad --- /dev/null +++ b/hw2/hw/shop_api/routes/carts.py @@ -0,0 +1,51 @@ +from fastapi import APIRouter, HTTPException, Query, status, Response +from typing import List, Optional +from ..models import Cart, CartCreateResponse +from ..storage import storage + +router = APIRouter(prefix="/cart", tags=["carts"]) + + +@router.post("/", response_model=CartCreateResponse, status_code=status.HTTP_201_CREATED) +def create_cart(response: Response): + cart_id = storage.create_cart() + response.headers["Location"] = f"/cart/{cart_id}" + return CartCreateResponse(id=cart_id) + + +@router.get("/{cart_id}", response_model=Cart) +def get_cart(cart_id: int): + cart = storage.get_cart(cart_id) + if not cart: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Cart not found") + return cart + + +@router.get("/", response_model=List[Cart]) +def list_carts( + offset: int = Query(0, ge=0), + limit: int = Query(10, ge=1), + 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) +): + def cart_filter(cart: Cart) -> bool: + total_quantity = sum(item.quantity for item in cart.items) + return all([ + min_price is None or cart.price >= min_price, + max_price is None or cart.price <= max_price, + min_quantity is None or total_quantity >= min_quantity, + max_quantity is None or total_quantity <= max_quantity + ]) + + filtered_carts = list(filter(cart_filter, storage.carts.values())) + return filtered_carts[offset:offset + limit] + + +@router.post("/{cart_id}/add/{item_id}") +def add_item_to_cart(cart_id: int, item_id: int): + success = storage.add_item_to_cart(cart_id, item_id) + if not success: + raise HTTPException(status_code=404, detail="Cart or item not found") + return {"message": "Item added to cart"} \ No newline at end of file diff --git a/hw2/hw/shop_api/routes/items.py b/hw2/hw/shop_api/routes/items.py new file mode 100644 index 00000000..fdd5fa0f --- /dev/null +++ b/hw2/hw/shop_api/routes/items.py @@ -0,0 +1,67 @@ +from fastapi import APIRouter, HTTPException, Query, status +from typing import List, Optional +from ..models import Item, ItemCreate, ItemUpdate +from ..storage import storage + +router = APIRouter(prefix="/item", tags=["items"]) + +@router.post("/", response_model=Item, status_code=status.HTTP_201_CREATED) +def create_item(item: ItemCreate): + return storage.create_item(item) + + +@router.get("/{item_id}", response_model=Item) +def get_item(item_id: int): + item = storage.get_available_item(item_id) + if not item: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Item not found") + return item + + +@router.get("/", response_model=List[Item]) +def list_items( + offset: int = Query(0, ge=0), + limit: int = Query(10, ge=1), + min_price: Optional[float] = Query(None, ge=0), + max_price: Optional[float] = Query(None, ge=0), + show_deleted: bool = False +): + items = storage.list_items(show_deleted=show_deleted) + + if min_price is not None: + items = [item for item in items if item.price >= min_price] + if max_price is not None: + items = [item for item in items if item.price <= max_price] + + return items[offset:offset + limit] + + +@router.put("/{item_id}", response_model=Item) +def replace_item(item_id: int, item: ItemCreate): + existing = storage.get_item(item_id) + if not existing: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Item not found") + + updated_item = storage.replace_item(item_id, item) + return updated_item + + +@router.patch("/{item_id}", response_model=Item) +def update_item(item_id: int, item_update: ItemUpdate): + existing_item = storage.get_available_item(item_id) + if not existing_item: + raise HTTPException(status_code=status.HTTP_304_NOT_MODIFIED, detail="Item not found or deleted") + + try: + item = storage.update_item(item_id, item_update) + return item + except ValueError as e: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(e)) + + +@router.delete("/{item_id}", response_model=Item) +def delete_item(item_id: int): + item = storage.delete_item(item_id) + if not item: + raise HTTPException(status_code=404, detail="Item not found") + return item \ No newline at end of file diff --git a/hw2/hw/shop_api/storage.py b/hw2/hw/shop_api/storage.py new file mode 100644 index 00000000..062cfcda --- /dev/null +++ b/hw2/hw/shop_api/storage.py @@ -0,0 +1,98 @@ +from typing import Dict, List, Optional +from .models import Item, Cart, CartItem, ItemCreate, ItemUpdate + + +class Storage: + def __init__(self): + self.items: Dict[int, Item] = {} + self.carts: Dict[int, Cart] = {} + self._next_item_id = 1 + self._next_cart_id = 1 + + def create_item(self, item_data: ItemCreate) -> Item: + item_id = self._next_item_id + self._next_item_id += 1 + item = Item(id=item_id, **item_data.model_dump()) + self.items[item_id] = item + return item + + def get_item(self, item_id: int) -> Optional[Item]: + return self.items.get(item_id) + + def get_available_item(self, item_id: int) -> Optional[Item]: + item = self.items.get(item_id) + return item if item and not item.deleted else None + + def list_items(self, show_deleted: bool = False) -> List[Item]: + items = list(self.items.values()) + if not show_deleted: + items = [item for item in items if not item.deleted] + return items + + def replace_item(self, item_id: int, item_data: ItemCreate) -> Optional[Item]: + if item_id not in self.items: + return None + + current_deleted = self.items[item_id].deleted + self.items[item_id] = Item( + id=item_id, + **item_data.model_dump(), + deleted=current_deleted + ) + return self.items[item_id] + + def update_item(self, item_id: int, update_data: ItemUpdate) -> Optional[Item]: + if item_id not in self.items: + return None + + item = self.items[item_id] + update_dict = update_data.model_dump(exclude_unset=True) + + for field, value in update_dict.items(): + setattr(item, field, value) + + return item + + def delete_item(self, item_id: int) -> Optional[Item]: + if item_id not in self.items: + return None + self.items[item_id].deleted = True + return self.items[item_id] + + def create_cart(self) -> int: + cart_id = self._next_cart_id + self._next_cart_id += 1 + cart = Cart(id=cart_id, items=[], price=0.0) + self.carts[cart_id] = cart + return cart_id + + def get_cart(self, cart_id: int) -> Optional[Cart]: + return self.carts.get(cart_id) + + def add_item_to_cart(self, cart_id: int, item_id: int) -> bool: + if cart_id not in self.carts: + return False + + item = self.get_available_item(item_id) + if not item: + return False + + cart = self.carts[cart_id] + + cart_item = CartItem( + id=item_id, + name=item.name, + quantity=1, + available=True + ) + cart.items.append(cart_item) + + cart.price = sum( + cart_item.quantity * self.items[cart_item.id].price + for cart_item in cart.items + if cart_item.available + ) + + return True + +storage = Storage() \ No newline at end of file diff --git a/hw3/Dockerfile b/hw3/Dockerfile new file mode 100644 index 00000000..d965c728 --- /dev/null +++ b/hw3/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.11-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +EXPOSE 8000 + +CMD ["uvicorn", "shop_api.main:app", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file diff --git a/hw3/README.md b/hw3/README.md new file mode 100644 index 00000000..7d889c7a --- /dev/null +++ b/hw3/README.md @@ -0,0 +1,15 @@ +# ДЗ + +## Настроить сборку образов Docker и мониторинг с помощью Prometheus и Grafana + +Интегрировать Docker с Prometheus и Grafana в любой уже написанный в ДЗ сервис (по аналогии с тем, как в репе) + +По сути, если вы выполнили вторую домашку, то теперь для неё надо написать Dockerfile и настроить мониторинг. Если вторую домашку вы не делали, то можно взять сервис из [rest_example](../hw2/rest_example/main.py) + +Сдача через PR, так же нужно: + +1) Dockerfile для сборки сервиса +2) docker-compose.yml для локального разворачивания в Docker +3) Приложить скрин с парой Дашбордов в Grafana + +![grafana.png](images/grafana.png) \ No newline at end of file diff --git a/hw3/__init__.py b/hw3/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hw3/docker-compose.yml b/hw3/docker-compose.yml new file mode 100644 index 00000000..ae64cbb5 --- /dev/null +++ b/hw3/docker-compose.yml @@ -0,0 +1,42 @@ +version: '3.8' + +services: + shop-api: + build: . + ports: + - "8000:8000" + volumes: + - .:/app + depends_on: + - prometheus + networks: + - monitoring + + prometheus: + image: prom/prometheus:latest + ports: + - "9090:9090" + volumes: + - ./prometheus.yml:/etc/prometheus/prometheus.yml + networks: + - monitoring + + grafana: + image: grafana/grafana:latest + ports: + - "3000:3000" + environment: + - GF_SECURITY_ADMIN_PASSWORD=admin + volumes: + - grafana-storage:/var/lib/grafana + depends_on: + - prometheus + networks: + - monitoring + +volumes: + grafana-storage: + +networks: + monitoring: + driver: bridge \ No newline at end of file diff --git a/hw3/images/grafana.png b/hw3/images/grafana.png new file mode 100644 index 00000000..7d141930 Binary files /dev/null and b/hw3/images/grafana.png differ diff --git a/hw3/prometheus.yml b/hw3/prometheus.yml new file mode 100644 index 00000000..e0c857fb --- /dev/null +++ b/hw3/prometheus.yml @@ -0,0 +1,7 @@ +global: + scrape_interval: 15s + +scrape_configs: + - job_name: 'shop-api' + static_configs: + - targets: ['shop-api:8000'] \ No newline at end of file diff --git a/hw3/requirements.txt b/hw3/requirements.txt new file mode 100644 index 00000000..7c42d154 --- /dev/null +++ b/hw3/requirements.txt @@ -0,0 +1,13 @@ +# Основные зависимости для ASGI приложения +fastapi>=0.117.1 +uvicorn>=0.24.0 + +# Зависимости для тестирования +pytest>=7.4.0 +pytest-asyncio>=0.21.0 +httpx>=0.27.2 +Faker>=37.8.0 + +# Мониторинг +prometheus-fastapi-instrumentator>=7.0.0 + diff --git a/hw3/shop_api/__init__.py b/hw3/shop_api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hw3/shop_api/chat/__init__.py b/hw3/shop_api/chat/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hw3/shop_api/chat/websocket.py b/hw3/shop_api/chat/websocket.py new file mode 100644 index 00000000..5d68e9ed --- /dev/null +++ b/hw3/shop_api/chat/websocket.py @@ -0,0 +1,93 @@ +import json +import uuid +from fastapi import APIRouter, WebSocket, WebSocketDisconnect +from typing import Dict, List +import asyncio + + +class ConnectionManager: + def __init__(self): + self.active_connections: Dict[str, List[WebSocket]] = {} + self.usernames: Dict[WebSocket, str] = {} + + async def connect(self, websocket: WebSocket, chat_name: str): + await websocket.accept() + + if chat_name not in self.active_connections: + self.active_connections[chat_name] = [] + + self.active_connections[chat_name].append(websocket) + + username = f"User_{uuid.uuid4().hex[:8]}" + self.usernames[websocket] = username + + await self.broadcast_message( + chat_name, + f"🟢 {username} :: присоединился к чату", + exclude_websocket=websocket + ) + + await websocket.send_text(f"🤖 Добро пожаловать в чат '{chat_name}'! Ваше имя: {username}") + + def disconnect(self, websocket: WebSocket, chat_name: str): + if chat_name in self.active_connections: + if websocket in self.active_connections[chat_name]: + self.active_connections[chat_name].remove(websocket) + + if not self.active_connections[chat_name]: + del self.active_connections[chat_name] + + if websocket in self.usernames: + username = self.usernames[websocket] + del self.usernames[websocket] + + if chat_name in self.active_connections: + self.broadcast_message_sync( + chat_name, + f"🔴 {username} :: покинул чат", + exclude_websocket=websocket + ) + + async def broadcast_message(self, chat_name: str, message: str, exclude_websocket: WebSocket = None): + if chat_name in self.active_connections: + for connection in self.active_connections[chat_name]: + if connection != exclude_websocket: + try: + await connection.send_text(message) + except: + self.disconnect(connection, chat_name) + + def broadcast_message_sync(self, chat_name: str, message: str, exclude_websocket: WebSocket = None): + """Синхронная версия для использования в disconnect""" + if chat_name in self.active_connections: + disconnected = [] + for connection in self.active_connections[chat_name]: + if connection != exclude_websocket: + try: + asyncio.create_task(connection.send_text(message)) + except: + disconnected.append(connection) + + for connection in disconnected: + self.disconnect(connection, chat_name) + + async def handle_message(self, websocket: WebSocket, chat_name: str, message: str): + username = self.usernames.get(websocket, "Unknown") + formatted_message = f"{username} :: {message}" + await self.broadcast_message(chat_name, formatted_message) + + +manager = ConnectionManager() +chat_router = APIRouter() + + +@chat_router.websocket("/chat/{chat_name}") +async def websocket_endpoint(websocket: WebSocket, chat_name: str): + await manager.connect(websocket, chat_name) + + try: + while True: + data = await websocket.receive_text() + await manager.handle_message(websocket, chat_name, data) + except WebSocketDisconnect: + manager.disconnect(websocket, chat_name) \ No newline at end of file diff --git a/hw3/shop_api/main.py b/hw3/shop_api/main.py new file mode 100644 index 00000000..3aa49093 --- /dev/null +++ b/hw3/shop_api/main.py @@ -0,0 +1,16 @@ +from fastapi import FastAPI +from prometheus_fastapi_instrumentator import Instrumentator +from .routes import items, carts +from .chat.websocket import chat_router + +app = FastAPI(title="Shop API") + +app.include_router(items.router, tags=["items"]) +app.include_router(carts.router, tags=["carts"]) +app.include_router(chat_router, tags=["chat"]) + +Instrumentator().instrument(app).expose(app) + +@app.get("/") +async def root(): + return {"message": "Shop API is running"} \ No newline at end of file diff --git a/hw3/shop_api/models.py b/hw3/shop_api/models.py new file mode 100644 index 00000000..263c94e1 --- /dev/null +++ b/hw3/shop_api/models.py @@ -0,0 +1,32 @@ +from pydantic import BaseModel, Field, ConfigDict +from typing import List, Optional + +class ItemBase(BaseModel): + name: str = Field(..., min_length=1, description="Наименование товара", examples=["Молоко \"Бурёнка\" 1л"]) + price: float = Field(..., gt=0, description="Цена товара", examples=[159.99]) + +class ItemCreate(ItemBase): + pass + +class ItemUpdate(BaseModel): + model_config = ConfigDict(extra='forbid') + name: Optional[str] = Field(None, min_length=1, description="Новое наименование товара", examples=["Молоко \"Новое\" 1л"]) + price: Optional[float] = Field(None, gt=0, description="Новая цена товара", examples=[169.99]) + +class Item(ItemBase): + id: int = Field(..., description="Уникальный идентификатор товара", examples=[321]) + deleted: bool = Field(False, description="Товар удален (soft delete)") + +class CartItem(BaseModel): + id: int = Field(..., gt=0, description="ID товара", examples=[1]) + name: str = Field(..., min_length=1, description="Название товара", examples=["Туалетная бумага \"Поцелуй\", рулон"]) + quantity: int = Field(..., ge=1, description="Количество товара в корзине", examples=[3]) + available: bool = Field(..., description="Товар доступен для заказа") + +class Cart(BaseModel): + id: int = Field(..., description="Уникальный идентификатор корзины", examples=[123]) + items: List[CartItem] = Field(..., description="Список товаров в корзине") + price: float = Field(..., description="Общая сумма заказа", examples=[234.4]) + +class CartCreateResponse(BaseModel): + id: int = Field(..., description="Идентификатор созданной корзины", examples=[123]) \ No newline at end of file diff --git a/hw3/shop_api/routes/carts.py b/hw3/shop_api/routes/carts.py new file mode 100644 index 00000000..6ad2a0ad --- /dev/null +++ b/hw3/shop_api/routes/carts.py @@ -0,0 +1,51 @@ +from fastapi import APIRouter, HTTPException, Query, status, Response +from typing import List, Optional +from ..models import Cart, CartCreateResponse +from ..storage import storage + +router = APIRouter(prefix="/cart", tags=["carts"]) + + +@router.post("/", response_model=CartCreateResponse, status_code=status.HTTP_201_CREATED) +def create_cart(response: Response): + cart_id = storage.create_cart() + response.headers["Location"] = f"/cart/{cart_id}" + return CartCreateResponse(id=cart_id) + + +@router.get("/{cart_id}", response_model=Cart) +def get_cart(cart_id: int): + cart = storage.get_cart(cart_id) + if not cart: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Cart not found") + return cart + + +@router.get("/", response_model=List[Cart]) +def list_carts( + offset: int = Query(0, ge=0), + limit: int = Query(10, ge=1), + 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) +): + def cart_filter(cart: Cart) -> bool: + total_quantity = sum(item.quantity for item in cart.items) + return all([ + min_price is None or cart.price >= min_price, + max_price is None or cart.price <= max_price, + min_quantity is None or total_quantity >= min_quantity, + max_quantity is None or total_quantity <= max_quantity + ]) + + filtered_carts = list(filter(cart_filter, storage.carts.values())) + return filtered_carts[offset:offset + limit] + + +@router.post("/{cart_id}/add/{item_id}") +def add_item_to_cart(cart_id: int, item_id: int): + success = storage.add_item_to_cart(cart_id, item_id) + if not success: + raise HTTPException(status_code=404, detail="Cart or item not found") + return {"message": "Item added to cart"} \ No newline at end of file diff --git a/hw3/shop_api/routes/items.py b/hw3/shop_api/routes/items.py new file mode 100644 index 00000000..fdd5fa0f --- /dev/null +++ b/hw3/shop_api/routes/items.py @@ -0,0 +1,67 @@ +from fastapi import APIRouter, HTTPException, Query, status +from typing import List, Optional +from ..models import Item, ItemCreate, ItemUpdate +from ..storage import storage + +router = APIRouter(prefix="/item", tags=["items"]) + +@router.post("/", response_model=Item, status_code=status.HTTP_201_CREATED) +def create_item(item: ItemCreate): + return storage.create_item(item) + + +@router.get("/{item_id}", response_model=Item) +def get_item(item_id: int): + item = storage.get_available_item(item_id) + if not item: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Item not found") + return item + + +@router.get("/", response_model=List[Item]) +def list_items( + offset: int = Query(0, ge=0), + limit: int = Query(10, ge=1), + min_price: Optional[float] = Query(None, ge=0), + max_price: Optional[float] = Query(None, ge=0), + show_deleted: bool = False +): + items = storage.list_items(show_deleted=show_deleted) + + if min_price is not None: + items = [item for item in items if item.price >= min_price] + if max_price is not None: + items = [item for item in items if item.price <= max_price] + + return items[offset:offset + limit] + + +@router.put("/{item_id}", response_model=Item) +def replace_item(item_id: int, item: ItemCreate): + existing = storage.get_item(item_id) + if not existing: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Item not found") + + updated_item = storage.replace_item(item_id, item) + return updated_item + + +@router.patch("/{item_id}", response_model=Item) +def update_item(item_id: int, item_update: ItemUpdate): + existing_item = storage.get_available_item(item_id) + if not existing_item: + raise HTTPException(status_code=status.HTTP_304_NOT_MODIFIED, detail="Item not found or deleted") + + try: + item = storage.update_item(item_id, item_update) + return item + except ValueError as e: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(e)) + + +@router.delete("/{item_id}", response_model=Item) +def delete_item(item_id: int): + item = storage.delete_item(item_id) + if not item: + raise HTTPException(status_code=404, detail="Item not found") + return item \ No newline at end of file diff --git a/hw3/shop_api/storage.py b/hw3/shop_api/storage.py new file mode 100644 index 00000000..062cfcda --- /dev/null +++ b/hw3/shop_api/storage.py @@ -0,0 +1,98 @@ +from typing import Dict, List, Optional +from .models import Item, Cart, CartItem, ItemCreate, ItemUpdate + + +class Storage: + def __init__(self): + self.items: Dict[int, Item] = {} + self.carts: Dict[int, Cart] = {} + self._next_item_id = 1 + self._next_cart_id = 1 + + def create_item(self, item_data: ItemCreate) -> Item: + item_id = self._next_item_id + self._next_item_id += 1 + item = Item(id=item_id, **item_data.model_dump()) + self.items[item_id] = item + return item + + def get_item(self, item_id: int) -> Optional[Item]: + return self.items.get(item_id) + + def get_available_item(self, item_id: int) -> Optional[Item]: + item = self.items.get(item_id) + return item if item and not item.deleted else None + + def list_items(self, show_deleted: bool = False) -> List[Item]: + items = list(self.items.values()) + if not show_deleted: + items = [item for item in items if not item.deleted] + return items + + def replace_item(self, item_id: int, item_data: ItemCreate) -> Optional[Item]: + if item_id not in self.items: + return None + + current_deleted = self.items[item_id].deleted + self.items[item_id] = Item( + id=item_id, + **item_data.model_dump(), + deleted=current_deleted + ) + return self.items[item_id] + + def update_item(self, item_id: int, update_data: ItemUpdate) -> Optional[Item]: + if item_id not in self.items: + return None + + item = self.items[item_id] + update_dict = update_data.model_dump(exclude_unset=True) + + for field, value in update_dict.items(): + setattr(item, field, value) + + return item + + def delete_item(self, item_id: int) -> Optional[Item]: + if item_id not in self.items: + return None + self.items[item_id].deleted = True + return self.items[item_id] + + def create_cart(self) -> int: + cart_id = self._next_cart_id + self._next_cart_id += 1 + cart = Cart(id=cart_id, items=[], price=0.0) + self.carts[cart_id] = cart + return cart_id + + def get_cart(self, cart_id: int) -> Optional[Cart]: + return self.carts.get(cart_id) + + def add_item_to_cart(self, cart_id: int, item_id: int) -> bool: + if cart_id not in self.carts: + return False + + item = self.get_available_item(item_id) + if not item: + return False + + cart = self.carts[cart_id] + + cart_item = CartItem( + id=item_id, + name=item.name, + quantity=1, + available=True + ) + cart.items.append(cart_item) + + cart.price = sum( + cart_item.quantity * self.items[cart_item.id].price + for cart_item in cart.items + if cart_item.available + ) + + return True + +storage = Storage() \ No newline at end of file diff --git a/hw3/test_homework2.py b/hw3/test_homework2.py new file mode 100644 index 00000000..60a1f36a --- /dev/null +++ b/hw3/test_homework2.py @@ -0,0 +1,284 @@ +from http import HTTPStatus +from typing import Any +from uuid import uuid4 + +import pytest +from faker import Faker +from fastapi.testclient import TestClient + +from shop_api.main import app + +client = TestClient(app) +faker = Faker() + + +@pytest.fixture() +def existing_empty_cart_id() -> int: + return client.post("/cart").json()["id"] + + +@pytest.fixture(scope="session") +def existing_items() -> list[int]: + items = [ + { + "name": f"Тестовый товар {i}", + "price": faker.pyfloat(positive=True, min_value=10.0, max_value=500.0), + } + for i in range(10) + ] + + return [client.post("/item", json=item).json()["id"] for item in items] + + +@pytest.fixture(scope="session", autouse=True) +def existing_not_empty_carts(existing_items: list[int]) -> list[int]: + carts = [] + + for i in range(20): + cart_id: int = client.post("/cart").json()["id"] + for item_id in faker.random_elements(existing_items, unique=False, length=i): + client.post(f"/cart/{cart_id}/add/{item_id}") + + carts.append(cart_id) + + return carts + + +@pytest.fixture() +def existing_not_empty_cart_id( + existing_empty_cart_id: int, + existing_items: list[int], +) -> int: + for item_id in faker.random_elements(existing_items, unique=False, length=3): + client.post(f"/cart/{existing_empty_cart_id}/add/{item_id}") + + return existing_empty_cart_id + + +@pytest.fixture() +def existing_item() -> dict[str, Any]: + return client.post( + "/item", + json={ + "name": f"Тестовый товар {uuid4().hex}", + "price": faker.pyfloat(min_value=10.0, max_value=100.0), + }, + ).json() + + +@pytest.fixture() +def deleted_item(existing_item: dict[str, Any]) -> dict[str, Any]: + item_id = existing_item["id"] + client.delete(f"/item/{item_id}") + + existing_item["deleted"] = True + return existing_item + + +def test_post_cart() -> None: + response = client.post("/cart") + + assert response.status_code == HTTPStatus.CREATED + assert "location" in response.headers + assert "id" in response.json() + + +@pytest.mark.parametrize( + ("cart", "not_empty"), + [ + ("existing_empty_cart_id", False), + ("existing_not_empty_cart_id", True), + ], +) +def test_get_cart(request, cart: int, not_empty: bool) -> None: + cart_id = request.getfixturevalue(cart) + + response = client.get(f"/cart/{cart_id}") + + assert response.status_code == HTTPStatus.OK + response_json = response.json() + + len_items = len(response_json["items"]) + assert len_items > 0 if not_empty else len_items == 0 + + if not_empty: + price = 0 + + for item in response_json["items"]: + item_id = item["id"] + price += client.get(f"/item/{item_id}").json()["price"] * item["quantity"] + + assert response_json["price"] == pytest.approx(price, 1e-8) + else: + assert response_json["price"] == 0.0 + + +@pytest.mark.parametrize( + ("query", "status_code"), + [ + ({}, HTTPStatus.OK), + ({"offset": 1, "limit": 2}, HTTPStatus.OK), + ({"min_price": 1000.0}, HTTPStatus.OK), + ({"max_price": 20.0}, HTTPStatus.OK), + ({"min_quantity": 1}, HTTPStatus.OK), + ({"max_quantity": 0}, HTTPStatus.OK), + ({"offset": -1}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"limit": 0}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"limit": -1}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"min_price": -1.0}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"max_price": -1.0}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"min_quantity": -1}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"max_quantity": -1}, HTTPStatus.UNPROCESSABLE_ENTITY), + ], +) +def test_get_cart_list(query: dict[str, Any], status_code: int): + response = client.get("/cart", params=query) + + assert response.status_code == status_code + + if status_code == HTTPStatus.OK: + data = response.json() + + assert isinstance(data, list) + + if "min_price" in query: + assert all(item["price"] >= query["min_price"] for item in data) + + if "max_price" in query: + assert all(item["price"] <= query["max_price"] for item in data) + + quantity = sum(item["quantity"] for cart in data for item in cart["items"]) + + if "min_quantity" in query: + assert quantity >= query["min_quantity"] + + if "max_quantity" in query: + assert quantity <= query["max_quantity"] + + +def test_post_item() -> None: + item = {"name": "test item", "price": 9.99} + response = client.post("/item", json=item) + + assert response.status_code == HTTPStatus.CREATED + + data = response.json() + assert item["price"] == data["price"] + assert item["name"] == data["name"] + + +def test_get_item(existing_item: dict[str, Any]) -> None: + item_id = existing_item["id"] + + response = client.get(f"/item/{item_id}") + + assert response.status_code == HTTPStatus.OK + assert response.json() == existing_item + + +@pytest.mark.parametrize( + ("query", "status_code"), + [ + ({"offset": 2, "limit": 5}, HTTPStatus.OK), + ({"min_price": 5.0}, HTTPStatus.OK), + ({"max_price": 5.0}, HTTPStatus.OK), + ({"show_deleted": True}, HTTPStatus.OK), + ({"offset": -1}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"limit": -1}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"limit": 0}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"min_price": -1}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"max_price": -1}, HTTPStatus.UNPROCESSABLE_ENTITY), + ], +) +def test_get_item_list(query: dict[str, Any], status_code: int) -> None: + response = client.get("/item", params=query) + + assert response.status_code == status_code + + if status_code == HTTPStatus.OK: + data = response.json() + + assert isinstance(data, list) + + if "min_price" in query: + assert all(item["price"] >= query["min_price"] for item in data) + + if "max_price" in query: + assert all(item["price"] <= query["max_price"] for item in data) + + if "show_deleted" in query and query["show_deleted"] is False: + assert all(item["deleted"] is False for item in data) + + +@pytest.mark.parametrize( + ("body", "status_code"), + [ + ({}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"price": 9.99}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"name": "new name", "price": 9.99}, HTTPStatus.OK), + ], +) +def test_put_item( + existing_item: dict[str, Any], + body: dict[str, Any], + status_code: int, +) -> None: + item_id = existing_item["id"] + response = client.put(f"/item/{item_id}", json=body) + + assert response.status_code == status_code + + if status_code == HTTPStatus.OK: + new_item = existing_item.copy() + new_item.update(body) + assert response.json() == new_item + + +@pytest.mark.parametrize( + ("item", "body", "status_code"), + [ + ("deleted_item", {}, HTTPStatus.NOT_MODIFIED), + ("deleted_item", {"price": 9.99}, HTTPStatus.NOT_MODIFIED), + ("deleted_item", {"name": "new name", "price": 9.99}, HTTPStatus.NOT_MODIFIED), + ("existing_item", {}, HTTPStatus.OK), + ("existing_item", {"price": 9.99}, HTTPStatus.OK), + ("existing_item", {"name": "new name", "price": 9.99}, HTTPStatus.OK), + ( + "existing_item", + {"name": "new name", "price": 9.99, "odd": "value"}, + HTTPStatus.UNPROCESSABLE_ENTITY, + ), + ( + "existing_item", + {"name": "new name", "price": 9.99, "deleted": True}, + HTTPStatus.UNPROCESSABLE_ENTITY, + ), + ], +) +def test_patch_item(request, item: str, body: dict[str, Any], status_code: int) -> None: + item_data: dict[str, Any] = request.getfixturevalue(item) + item_id = item_data["id"] + response = client.patch(f"/item/{item_id}", json=body) + + assert response.status_code == status_code + + if status_code == HTTPStatus.OK: + patch_response_body = response.json() + + response = client.get(f"/item/{item_id}") + patched_item = response.json() + + assert patched_item == patch_response_body + + +def test_delete_item(existing_item: dict[str, Any]) -> None: + item_id = existing_item["id"] + + response = client.delete(f"/item/{item_id}") + assert response.status_code == HTTPStatus.OK + + response = client.get(f"/item/{item_id}") + assert response.status_code == HTTPStatus.NOT_FOUND + + response = client.delete(f"/item/{item_id}") + assert response.status_code == HTTPStatus.OK diff --git a/lecture3/Dockerfile b/lecture3/Dockerfile new file mode 100644 index 00000000..1eaf1db1 --- /dev/null +++ b/lecture3/Dockerfile @@ -0,0 +1,23 @@ +FROM python:3.12 AS base + +ARG PYTHONFAULTHANDLER=1 \ + PYTHONUNBUFFERED=1 \ + PYTHONHASHSEED=random \ + PIP_NO_CACHE_DIR=on \ + PIP_DISABLE_PIP_VERSION_CHECK=on \ + PIP_DEFAULT_TIMEOUT=500 + +RUN apt-get update && apt-get install -y gcc +RUN python -m pip install --upgrade pip + +WORKDIR $APP_ROOT/src +COPY . ./ + +ENV VIRTUAL_ENV=$APP_ROOT/src/.venv \ + PATH=$APP_ROOT/src/.venv/bin:$PATH + +RUN pip install -r requirements.txt + +FROM base as local + +CMD ["uvicorn", "demo_service.api:app", "--port", "8080", "--host", "0.0.0.0"] diff --git a/lecture3/README.md b/lecture3/README.md new file mode 100644 index 00000000..aad28c54 --- /dev/null +++ b/lecture3/README.md @@ -0,0 +1,13 @@ +# ДЗ + +## Настроить сборку образов Docker и мониторинг с помощью Prometheus и Grafana + +Интегрировать Docker с Prometheus и Grafana в любой уже написанный в ДЗ сервис (по аналогии с тем, как в репе) + +По сути, если вы выполнили вторую домашку, то теперь для неё надо написать Dockerfile и настроить мониторинг. Если вторую домашку вы не делали, то можно взять сервис из [rest_example](../hw2/rest_example/main.py) + +Сдача через PR, так же нужно: + +1) Dockerfile для сборки сервиса +2) docker-compose.yml для локального разворачивания в Docker +3) Приложить скрин с парой Дашбордов в Grafana diff --git a/lecture3/ddoser.py b/lecture3/ddoser.py new file mode 100644 index 00000000..fdc10f76 --- /dev/null +++ b/lecture3/ddoser.py @@ -0,0 +1,44 @@ +from concurrent.futures import ThreadPoolExecutor, as_completed + +import requests +from faker import Faker + +faker = Faker() + + +def create_users(): + for _ in range(500): + user = faker.profile() + response = requests.post( + "http://localhost:8080/create-user", + json={ + "username": user["username"], + "first_name": user["name"], + "last_name": "", + }, + ) + + print(response) + + +def get_users(): + for _ in range(500): + + response = requests.post( + "http://localhost:8080/get-user", + params={"id": faker.random_number(digits=2)}, + ) + print(response) + + +with ThreadPoolExecutor() as executor: + futures = {} + + for i in range(15): + futures[executor.submit(create_users)] = f"create-user-{i}" + + for _ in range(15): + futures[executor.submit(get_users)] = f"get-users-{i}" + + for future in as_completed(futures): + print(f"completed {futures[future]}") diff --git a/lecture3/demo_service/__init__.py b/lecture3/demo_service/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/lecture3/demo_service/api.py b/lecture3/demo_service/api.py new file mode 100644 index 00000000..7c6bce40 --- /dev/null +++ b/lecture3/demo_service/api.py @@ -0,0 +1,42 @@ +from http import HTTPStatus +from typing import Annotated +import random + +from fastapi import FastAPI, HTTPException, Query +from prometheus_fastapi_instrumentator import Instrumentator + +from demo_service import store +from demo_service.contracts import UserRequest, UserResource + +app = FastAPI(title="Demo User API") +Instrumentator().instrument(app).expose(app) + + +def maybe_raise_random_error(): + if random.random() < 0.1: + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Random error occurred" + ) + + +@app.post( + "/create-user", + response_model=UserResource, + status_code=HTTPStatus.CREATED, +) +async def create_user(body: UserRequest) -> UserResource: + maybe_raise_random_error() + return store.insert(body) + + +@app.post("/get-user") +async def get_user(id: Annotated[int, Query()]) -> UserResource: + maybe_raise_random_error() + + resource = store.select(id) + + if not resource: + raise HTTPException(HTTPStatus.NOT_FOUND) + + return resource diff --git a/lecture3/demo_service/contracts.py b/lecture3/demo_service/contracts.py new file mode 100644 index 00000000..72b2ce89 --- /dev/null +++ b/lecture3/demo_service/contracts.py @@ -0,0 +1,18 @@ +from datetime import datetime + +from pydantic import BaseModel + + +class UserResource(BaseModel): + uid: int + username: str + first_name: str + last_name: str + birthdate: datetime | None = None + + +class UserRequest(BaseModel): + username: str + first_name: str + last_name: str + birthdate: datetime | None = None diff --git a/lecture3/demo_service/store.py b/lecture3/demo_service/store.py new file mode 100644 index 00000000..a88a7cfb --- /dev/null +++ b/lecture3/demo_service/store.py @@ -0,0 +1,27 @@ +from typing import Iterable + +from demo_service.contracts import UserRequest, UserResource + + +def _generate_int_id() -> Iterable[int]: + i = 0 + while True: + yield i + i += 1 + + +_users = dict[int, UserResource]() +_id_generator = _generate_int_id() + + +def insert(user: UserRequest) -> UserResource: + id = next(_id_generator) + resource = UserResource(uid=id, **user.model_dump()) + + _users[id] = resource + + return resource + + +def select(id: int) -> UserResource | None: + return _users.get(id, None) diff --git a/lecture3/docker-compose.yml b/lecture3/docker-compose.yml new file mode 100644 index 00000000..91b5555c --- /dev/null +++ b/lecture3/docker-compose.yml @@ -0,0 +1,31 @@ +version: "3" + +services: + + local: + build: + context: . + dockerfile: ./Dockerfile + target: local + restart: always + ports: + - 8080:8080 + + grafana: + image: grafana/grafana:latest + ports: + - 3000:3000 + restart: always + + prometheus: + image: prom/prometheus + volumes: + - ./settings/prometheus/:/etc/prometheus/ + command: + - "--config.file=/etc/prometheus/prometheus.yml" + - "--storage.tsdb.path=/prometheus" + - "--web.console.libraries=/usr/share/prometheus/console_libraries" + - "--web.console.templates=/usr/share/prometheus/consoles" + ports: + - 9090:9090 + restart: always diff --git a/lecture3/requirements.txt b/lecture3/requirements.txt new file mode 100644 index 00000000..bb6b4134 --- /dev/null +++ b/lecture3/requirements.txt @@ -0,0 +1,3 @@ +fastapi +uvicorn +prometheus-fastapi-instrumentator \ No newline at end of file diff --git a/lecture3/settings/prometheus/prometheus.yml b/lecture3/settings/prometheus/prometheus.yml new file mode 100644 index 00000000..6bdf88e7 --- /dev/null +++ b/lecture3/settings/prometheus/prometheus.yml @@ -0,0 +1,10 @@ +global: + scrape_interval: 10s + evaluation_interval: 10s + +scrape_configs: + - job_name: demo-service-local + metrics_path: /metrics + static_configs: + - targets: + - local:8080