diff --git a/hw1/app.py b/hw1/app.py index 6107b870..f891c4fd 100644 --- a/hw1/app.py +++ b/hw1/app.py @@ -1,4 +1,109 @@ from typing import Any, Awaitable, Callable +from operations import mean_number, fibonacci, factorial + +import asyncio +import ast +import json + + +async def handle_query( + send: Callable[[dict[str, Any]], Awaitable[None]], + body: bytes | None, + query_path: str | None, + query_params: bytes | None +): + + if not query_path: + op_type = '' + else: + op_type = query_path.split('/')[1] + + try: + if op_type not in {'mean', 'fibonacci', 'factorial'}: + raise ValueError("Unknown or wrong operation type") + + elif op_type == 'mean': + if query_params: + raise ValueError("No query params supported for this operation") + if not body: + raise ValueError("Parameters should be in the body") + numbers = body.decode() + if numbers == 'null': + raise ValueError("Body should consist of the list only") + numbers = ast.literal_eval(numbers) + if not isinstance(numbers, list): + raise ValueError("Body should consist of the list only") + result = str(await asyncio.to_thread(mean_number, numbers)) + + elif op_type == 'fibonacci': + if query_params: + raise ValueError("No query params supported for this operation") + if body: + raise ValueError("Body should be empty for this operation") + + n = query_path.split('/')[2] + if not n.lstrip('-').isnumeric(): + raise ValueError("Parameter is not a number") + n = int(n) + result = str(await asyncio.to_thread(fibonacci, n)) + + elif op_type == 'factorial': + if not query_params: + raise ValueError("Arguments should be in query params") + if body: + raise ValueError("Body should be empty for this operation") + + params = query_params.decode().split('=') + if len(params) != 2: + raise ValueError("Specify only one parameter") + if params[0] != 'n': + raise ValueError("Parameter name should be `n`") + if params[1] == '': + raise ValueError("Empty parameter value") + if not params[1].lstrip('-').isnumeric(): + raise ValueError("Parameter is not a number") + n = int(params[1]) + result = str(await asyncio.to_thread(factorial, n)) + + status_code = 200 + body = json.dumps({"result": result}) + + except ValueError as e: + body = str(e) + if body == "Unknown or wrong operation type": + status_code = 404 + elif body in {"No query params supported for this operation", + "Arguments should be in query params", + "Parameters should be in body", + "Body should be empty for this operations", + "Body should consist of the list only", + "Specify only one parameter", + "Parameter name should be `n`", + "Empty parameter value", "Parameter is not a number"}: + status_code = 422 + else: + status_code = 400 + except ZeroDivisionError: + body = "Empty list for mean calculation" + status_code = 400 + except IndexError: + body = "No number for Fibonacci calculation" + status_code = 422 + except TypeError: + body = "Operation can be performed only with numbers" + status_code = 400 + + finally: + await send( + { + "type": "http.response.start", + "status": status_code, + "headers": [ + [b"content-type", b"text/plain"], + ], + } + ) + await send({"type": "http.response.body", "body": body.encode('utf-8')}) async def application( @@ -12,7 +117,25 @@ async def application( receive: Корутина для получения сообщений от клиента send: Корутина для отправки сообщений клиенту """ - # TODO: Ваша реализация здесь + + if scope["type"] == 'lifespan': + while True: + body_dict = await receive() + if body_dict['type'] == 'lifespan.startup': + await send({'type': 'lifespan.startup.complete'}) + elif body_dict['type'] == 'lifespan.shutdown': + await send({'type': 'lifespan.shutdown.complete'}) + return + + if scope["type"] == 'http': + body_dict = await receive() + body = body_dict['body'] + + query_path = scope['path'] + query_params = scope['query_string'] + + await handle_query(send, body, query_path, query_params) + if __name__ == "__main__": import uvicorn diff --git a/hw1/operations.py b/hw1/operations.py new file mode 100644 index 00000000..e8624b4c --- /dev/null +++ b/hw1/operations.py @@ -0,0 +1,53 @@ +import math + + +def factorial(n: int) -> int: + try: + return math.factorial(n) + except ValueError: + raise + + +def mean_number(numbers: list[int]) -> int | float: + try: + return sum(numbers) / len(numbers) + except Exception: + raise + + +def pow(x, n, I, mult): + """ + Возвращает x в степени n. Предполагает, что I – это единичная матрица, которая + перемножается с mult, а n – положительное целое + """ + if n == 0: + return I + elif n == 1: + return x + else: + y = pow(x, n // 2, I, mult) + y = mult(y, y) + if n % 2: + y = mult(x, y) + return y + + +def identity_matrix(n): + """Возвращает единичную матрицу n на n""" + r = list(range(n)) + return [[1 if i == j else 0 for i in r] for j in r] + + +def matrix_multiply(A, B): + BT = list(zip(*B)) + return [[sum(a * b + for a, b in zip(row_a, col_b)) + for col_b in BT] + for row_a in A] + + +def fibonacci(n): + if n < 0: + raise ValueError('Number for Fibonacci should be >= 0') + F = pow([[1, 1], [1, 0]], n, identity_matrix(2), matrix_multiply) + return F[0][1] diff --git a/hw2/hw/Dockerfile b/hw2/hw/Dockerfile new file mode 100644 index 00000000..d01e1598 --- /dev/null +++ b/hw2/hw/Dockerfile @@ -0,0 +1,9 @@ +FROM python:3.11-slim + +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY shop_api/ ./shop_api/ +EXPOSE 8088 + +CMD ["uvicorn", "shop_api.main:app", "--host", "0.0.0.0", "--port", "8088"] diff --git a/hw2/hw/README_hw3.md b/hw2/hw/README_hw3.md new file mode 100644 index 00000000..be3cd561 --- /dev/null +++ b/hw2/hw/README_hw3.md @@ -0,0 +1,10 @@ +# Домашнее задание №3 + +Сделано на основе ДЗ2 (API магазина). Создан общий docker-compose.yaml и Dockerfile отдельно для приложения. + +Реализован мониторинг следующих метрик с помощью Prometheus и Grafana: количество успешных запросов, количество неуспешных запросов, количество запросов на замену товара (PUT, PATCH), количество имеющихся в базе товаров (с учетом удаленных). + +На основе файла с тестами написан скрипт **load_test.py**, который отправляет указанное количество случайных запросов к API на различные эндпоинты. + +Пример дашборда: +![пример дашборда](./res/hw_3.png) \ No newline at end of file diff --git a/hw2/hw/docker-compose.yml b/hw2/hw/docker-compose.yml new file mode 100644 index 00000000..f0a4119b --- /dev/null +++ b/hw2/hw/docker-compose.yml @@ -0,0 +1,55 @@ +version: '3.8' + +services: + shop-api: + build: . + ports: + - "8088:8088" + environment: + - PYTHONPATH=/app + networks: + - monitoring + restart: unless-stopped + + prometheus: + image: prom/prometheus:latest + ports: + - "9090:9090" + volumes: + - ./prometheus.yml:/etc/prometheus/prometheus.yml + - prometheus_data:/prometheus + command: + - '--config.file=/etc/prometheus/prometheus.yml' + - '--storage.tsdb.path=/prometheus' + - '--web.console.libraries=/etc/prometheus/console_libraries' + - '--web.console.templates=/etc/prometheus/consoles' + - '--storage.tsdb.retention.time=200h' + - '--web.enable-lifecycle' + networks: + - monitoring + restart: unless-stopped + + grafana: + image: grafana/grafana:latest + ports: + - "3000:3000" + volumes: + - grafana_data:/var/lib/grafana + - ./grafana/provisioning:/etc/grafana/provisioning + - ./grafana/dashboards:/var/lib/grafana/dashboards + environment: + - GF_SECURITY_ADMIN_PASSWORD=admin + - GF_USERS_ALLOW_SIGN_UP=false + networks: + - monitoring + restart: unless-stopped + depends_on: + - prometheus + +volumes: + prometheus_data: + grafana_data: + +networks: + monitoring: + driver: bridge diff --git a/hw2/hw/grafana/dashboards/shop-api-dashboard.json b/hw2/hw/grafana/dashboards/shop-api-dashboard.json new file mode 100644 index 00000000..cd688115 --- /dev/null +++ b/hw2/hw/grafana/dashboards/shop-api-dashboard.json @@ -0,0 +1,440 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": null, + "links": [], + "liveNow": false, + "panels": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "vis": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "sum(rate(http_requests_total{status=~\"2..\"}[5m]))", + "interval": "", + "legendFormat": "Successful Requests", + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "sum(rate(http_requests_total{status=~\"[45]..\"}[5m]))", + "hide": false, + "interval": "", + "legendFormat": "Unsuccessful Requests", + "refId": "B" + } + ], + "title": "Request Rate - Success vs Error", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 0 + }, + "id": 2, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "pluginVersion": "8.5.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "sum(http_requests_total{method=\"PUT\",handler=\"/item/{id}\"})", + "interval": "", + "legendFormat": "Total PUT Requests", + "refId": "A" + } + ], + "title": "Number of PUT Requests (Replacements)", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 8 + }, + "id": 3, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "pluginVersion": "8.5.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "sum(http_requests_total{method=\"PATCH\",handler=\"/item/{id}\"})", + "interval": "", + "legendFormat": "Total PATCH Requests", + "refId": "A" + } + ], + "title": "Number of PATCH Requests", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 8 + }, + "id": 4, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "pluginVersion": "8.5.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "sum(http_requests_total{method=\"POST\",handler=\"/item\"}) - sum(http_requests_total{method=\"DELETE\",handler=\"/item/{id}\"})", + "interval": "", + "legendFormat": "Active Items (Created - Deleted)", + "refId": "A" + } + ], + "title": "Number of Active Items (Excluding Deleted)", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "vis": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 24, + "x": 0, + "y": 16 + }, + "id": 5, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "rate(http_requests_total{method=\"PUT\",handler=\"/item/{id}\"}[5m])", + "interval": "", + "legendFormat": "PUT Rate (Replacements per second)", + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "rate(http_requests_total{method=\"PATCH\",handler=\"/item/{id}\"}[5m])", + "hide": false, + "interval": "", + "legendFormat": "PATCH Rate (Patches per second)", + "refId": "B" + } + ], + "title": "Average Operations Rate", + "type": "timeseries" + } + ], + "refresh": "5s", + "schemaVersion": 27, + "style": "dark", + "tags": [ + "shop-api" + ], + "templating": { + "list": [] + }, + "time": { + "from": "now-1h", + "to": "now" + }, + "timepicker": {}, + "timezone": "", + "title": "Shop API Metrics Dashboard", + "uid": "shop-api-dashboard", + "version": 1, + "weekStart": "" +} diff --git a/hw2/hw/grafana/provisioning/dashboards/dashboard.yml b/hw2/hw/grafana/provisioning/dashboards/dashboard.yml new file mode 100644 index 00000000..7435f09d --- /dev/null +++ b/hw2/hw/grafana/provisioning/dashboards/dashboard.yml @@ -0,0 +1,12 @@ +apiVersion: 1 + +providers: + - name: 'default' + orgId: 1 + folder: '' + type: file + disableDeletion: false + updateIntervalSeconds: 10 + allowUiUpdates: true + options: + path: /var/lib/grafana/dashboards diff --git a/hw2/hw/grafana/provisioning/datasources/prometheus.yml b/hw2/hw/grafana/provisioning/datasources/prometheus.yml new file mode 100644 index 00000000..a221c3c3 --- /dev/null +++ b/hw2/hw/grafana/provisioning/datasources/prometheus.yml @@ -0,0 +1,9 @@ +apiVersion: 1 + +datasources: + - name: prometheus + type: prometheus + access: proxy + url: http://prometheus:9090 + isDefault: true + editable: true diff --git a/hw2/hw/load_test.py b/hw2/hw/load_test.py new file mode 100644 index 00000000..74698972 --- /dev/null +++ b/hw2/hw/load_test.py @@ -0,0 +1,305 @@ +#!/usr/bin/env python3 +""" +Load testing script for Shop API +Generates random queries similar to test_homework2.py but without assertions +""" + +import asyncio +import random +import time +from typing import Any, List +from uuid import uuid4 +import httpx +from faker import Faker + +# Configuration +API_BASE_URL = "http://localhost:8088" +NUM_REQUESTS = 1000 +CONCURRENT_REQUESTS = 10 +REQUEST_DELAY = 0.1 + +faker = Faker() + + +class ShopAPILoadTester: + def __init__(self, base_url: str = API_BASE_URL): + self.base_url = base_url + self.client = httpx.AsyncClient(timeout=30.0, follow_redirects=True) + self.created_items: List[int] = [] + self.deleted_items: List[int] = [] + self.created_carts: List[int] = [] + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + await self.client.aclose() + + @property + def available_items(self) -> List[int]: + """Get list of items that haven't been deleted""" + return [item_id for item_id in self.created_items if item_id not in self.deleted_items] + + async def create_item(self) -> dict[str, Any]: + """Create a new item""" + item_data = { + "name": f"Load Test Item {uuid4().hex[:8]}", + "price": faker.pyfloat(positive=True, min_value=10.0, max_value=500.0) + } + + try: + response = await self.client.post(f"{self.base_url}/item", json=item_data) + if response.status_code == 201: + item = response.json() + self.created_items.append(item["id"]) + return item + except Exception as e: + print(f"Error creating item: {e}") + return {} + + async def get_item(self, item_id: int) -> dict[str, Any]: + """Get item by ID""" + try: + response = await self.client.get(f"{self.base_url}/item/{item_id}") + if response.status_code == 200: + return response.json() + except Exception as e: + print(f"Error getting item {item_id}: {e}") + return {} + + async def get_items_list(self, **params) -> List[dict[str, Any]]: + """Get list of items with optional filters""" + try: + response = await self.client.get(f"{self.base_url}/item", params=params) + if response.status_code == 200: + return response.json() + except Exception as e: + print(f"Error getting items list: {e}") + return [] + + async def update_item_put(self, item_id: int, item_data: dict[str, Any]) -> dict[str, Any]: + """Update item using PUT (full replacement)""" + try: + response = await self.client.put(f"{self.base_url}/item/{item_id}", json=item_data) + if response.status_code == 200: + return response.json() + except Exception as e: + print(f"Error updating item {item_id} with PUT: {e}") + return {} + + async def update_item_patch(self, item_id: int, item_data: dict[str, Any]) -> dict[str, Any]: + """Update item using PATCH (partial update)""" + try: + response = await self.client.patch(f"{self.base_url}/item/{item_id}", json=item_data) + if response.status_code == 200: + return response.json() + except Exception as e: + print(f"Error updating item {item_id} with PATCH: {e}") + return {} + + async def delete_item(self, item_id: int) -> dict[str, Any]: + """Delete item (soft delete)""" + try: + response = await self.client.delete(f"{self.base_url}/item/{item_id}") + if response.status_code == 200: + # Track that this item was deleted + if item_id not in self.deleted_items: + self.deleted_items.append(item_id) + return response.json() + except Exception as e: + print(f"Error deleting item {item_id}: {e}") + return {} + + async def create_cart(self) -> dict[str, Any]: + """Create a new cart""" + try: + response = await self.client.post(f"{self.base_url}/cart") + if response.status_code == 201: + cart = response.json() + self.created_carts.append(cart["id"]) + return cart + except Exception as e: + print(f"Error creating cart: {e}") + return {} + + async def get_cart(self, cart_id: int) -> dict[str, Any]: + """Get cart by ID""" + try: + response = await self.client.get(f"{self.base_url}/cart/{cart_id}") + if response.status_code == 200: + return response.json() + except Exception as e: + print(f"Error getting cart {cart_id}: {e}") + return {} + + async def get_carts_list(self, **params) -> List[dict[str, Any]]: + """Get list of carts with optional filters""" + try: + response = await self.client.get(f"{self.base_url}/cart", params=params) + if response.status_code == 200: + return response.json() + except Exception as e: + print(f"Error getting carts list: {e}") + return [] + + async def add_item_to_cart(self, cart_id: int, item_id: int) -> dict[str, Any]: + """Add item to cart""" + try: + response = await self.client.post(f"{self.base_url}/cart/{cart_id}/add/{item_id}") + if response.status_code == 200: + return response.json() + except Exception as e: + print(f"Error adding item {item_id} to cart {cart_id}: {e}") + return {} + + async def random_item_operation(self): + """Perform a random item operation""" + operations = [ + self.create_item, + self.get_item, + self.get_items_list, + self.update_item_put, + self.update_item_patch, + self.delete_item, + ] + + operation = random.choice(operations) + + if operation == self.create_item: + await operation() + + elif operation == self.get_item: + if self.available_items: + item_id = random.choice(self.available_items) + await operation(item_id) + + elif operation == self.get_items_list: + params = {} + if random.random() < 0.3: # 30% chance to add filters + if random.random() < 0.5: + params["min_price"] = faker.pyfloat(positive=True, min_value=10.0, max_value=100.0) + if random.random() < 0.5: + params["max_price"] = faker.pyfloat(positive=True, min_value=200.0, max_value=500.0) + if random.random() < 0.3: + params["show_deleted"] = random.choice([True, False]) + if random.random() < 0.5: + params["offset"] = random.randint(0, 10) + if random.random() < 0.5: + params["limit"] = random.randint(1, 20) + await operation(**params) + + elif operation == self.update_item_put: + if self.available_items: + item_id = random.choice(self.available_items) + item_data = { + "name": f"Updated Item {uuid4().hex[:8]}", + "price": faker.pyfloat(positive=True, min_value=10.0, max_value=500.0) + } + await operation(item_id, item_data) + + elif operation == self.update_item_patch: + if self.available_items: + item_id = random.choice(self.available_items) + item_data = {} + if random.random() < 0.7: # 70% chance to update name + item_data["name"] = f"Patched Item {uuid4().hex[:8]}" + if random.random() < 0.7: # 70% chance to update price + item_data["price"] = faker.pyfloat(positive=True, min_value=10.0, max_value=500.0) + if item_data: # Only patch if we have something to update + await operation(item_id, item_data) + + elif operation == self.delete_item: + if self.available_items: + item_id = random.choice(self.available_items) + await operation(item_id) + + async def random_cart_operation(self): + """Perform a random cart operation""" + operations = [ + self.create_cart, + self.get_cart, + self.get_carts_list, + self.add_item_to_cart, + ] + + operation = random.choice(operations) + + if operation == self.create_cart: + await operation() + + elif operation == self.get_cart: + if self.created_carts: + cart_id = random.choice(self.created_carts) + await operation(cart_id) + + elif operation == self.get_carts_list: + params = {} + if random.random() < 0.3: # 30% chance to add filters + if random.random() < 0.5: + params["min_price"] = faker.pyfloat(positive=True, min_value=10.0, max_value=100.0) + if random.random() < 0.5: + params["max_price"] = faker.pyfloat(positive=True, min_value=200.0, max_value=500.0) + if random.random() < 0.5: + params["min_quantity"] = random.randint(0, 5) + if random.random() < 0.5: + params["max_quantity"] = random.randint(5, 20) + if random.random() < 0.5: + params["offset"] = random.randint(0, 10) + if random.random() < 0.5: + params["limit"] = random.randint(1, 20) + await operation(**params) + + elif operation == self.add_item_to_cart: + if self.created_carts and self.available_items: + cart_id = random.choice(self.created_carts) + item_id = random.choice(self.available_items) + await operation(cart_id, item_id) + + async def run_load_test(self, num_requests: int = NUM_REQUESTS, delay: float = REQUEST_DELAY): + """Run the load test""" + print(f"Starting load test with {num_requests} requests...") + print(f"API Base URL: {self.base_url}") + print(f"Request delay: {delay}s") + print("-" * 50) + + start_time = time.time() + + for i in range(num_requests): + # Randomly choose between item and cart operations (70% items, 30% carts) + if random.random() < 0.7: + await self.random_item_operation() + else: + await self.random_cart_operation() + + # Print progress every 100 requests + if (i + 1) % 100 == 0: + elapsed = time.time() - start_time + rate = (i + 1) / elapsed + print(f"Completed {i + 1}/{num_requests} requests (Rate: {rate:.2f} req/s)") + + # Add delay between requests + if delay > 0: + await asyncio.sleep(delay) + + total_time = time.time() - start_time + avg_rate = num_requests / total_time + + print("-" * 50) + print(f"Load test completed!") + print(f"Total requests: {num_requests}") + print(f"Total time: {total_time:.2f}s") + print(f"Average rate: {avg_rate:.2f} req/s") + print(f"Created items: {len(self.created_items)}") + print(f"Available items: {len(self.available_items)}") + print(f"Deleted items: {len(self.deleted_items)}") + print(f"Created carts: {len(self.created_carts)}") + + +async def main(): + """Main function""" + async with ShopAPILoadTester() as tester: + await tester.run_load_test() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/hw2/hw/prometheus.yml b/hw2/hw/prometheus.yml new file mode 100644 index 00000000..d42cacf9 --- /dev/null +++ b/hw2/hw/prometheus.yml @@ -0,0 +1,18 @@ +global: + scrape_interval: 15s + evaluation_interval: 15s + +rule_files: + # - "first_rules.yml" + # - "second_rules.yml" + +scrape_configs: + - job_name: 'prometheus' + static_configs: + - targets: ['localhost:9090'] + + - job_name: 'shop-api' + static_configs: + - targets: ['shop-api:8088'] + metrics_path: '/metrics' + scrape_interval: 5s diff --git a/hw2/hw/requirements.txt b/hw2/hw/requirements.txt index 207dcf5c..4c786a81 100644 --- a/hw2/hw/requirements.txt +++ b/hw2/hw/requirements.txt @@ -7,3 +7,5 @@ pytest>=7.4.0 pytest-asyncio>=0.21.0 httpx>=0.27.2 Faker>=37.8.0 +aiosqlite==0.21.0 +prometheus-fastapi-instrumentator==7.1.0 diff --git a/hw2/hw/res/hw_3.png b/hw2/hw/res/hw_3.png new file mode 100644 index 00000000..a8483be4 Binary files /dev/null and b/hw2/hw/res/hw_3.png differ diff --git a/hw2/hw/shop_api/main.py b/hw2/hw/shop_api/main.py index f60a8c60..631503a6 100644 --- a/hw2/hw/shop_api/main.py +++ b/hw2/hw/shop_api/main.py @@ -1,3 +1,421 @@ -from fastapi import FastAPI +from fastapi import ( + FastAPI, APIRouter, Query, Path, Depends, HTTPException, status, Response +) +from typing import Optional +from contextlib import asynccontextmanager +import aiosqlite +from prometheus_fastapi_instrumentator import Instrumentator -app = FastAPI(title="Shop API") +conn: aiosqlite.Connection | None = None + +SCHEMA_SQL = """ +CREATE TABLE IF NOT EXISTS item ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + price REAL NOT NULL, + deleted INTEGER NOT NULL DEFAULT 0 +); + +CREATE TABLE IF NOT EXISTS cart ( + id INTEGER PRIMARY KEY AUTOINCREMENT +); + +CREATE TABLE IF NOT EXISTS cart_item ( + cart_id INTEGER NOT NULL, + item_id INTEGER NOT NULL, + quantity INTEGER NOT NULL DEFAULT 1, + PRIMARY KEY (cart_id, item_id) +); +""" + + +@asynccontextmanager +async def lifespan(app: FastAPI): + global conn + conn = await aiosqlite.connect(":memory:") + conn.row_factory = aiosqlite.Row + await conn.executescript(SCHEMA_SQL) + yield + await conn.close() + +app = FastAPI(title="Shop API", lifespan=lifespan) +api_router_cart = APIRouter(prefix='/cart') +api_router_item = APIRouter(prefix='/item') + + +instrumentator = Instrumentator() +instrumentator.instrument(app).expose(app) + + +async def get_conn() -> aiosqlite.Connection: + global conn + if conn is None: + conn = await aiosqlite.connect(":memory:") + conn.row_factory = aiosqlite.Row + await conn.executescript(SCHEMA_SQL) + return conn + + +@api_router_cart.post('/', status_code=status.HTTP_201_CREATED) +async def create_cart(response: Response, db: aiosqlite.Connection = Depends(get_conn)): + cursor = await db.execute("INSERT INTO cart DEFAULT VALUES") + await db.commit() + cart_id = cursor.lastrowid + response.headers["location"] = f"/cart/{cart_id}" + return {"id": cart_id} + + +@api_router_cart.get('/{id}', status_code=status.HTTP_200_OK) +async def get_cart_by_id( + id: int = Path(ge=0, description="ID корзины"), + db: aiosqlite.Connection = Depends(get_conn) +): + cart_exists = await db.execute("SELECT id FROM cart WHERE id = ?", (id,)) + if not await cart_exists.fetchone(): + + raise HTTPException(status_code=404, detail="Cart not found") + + query = """ + SELECT + ci.item_id, + ci.quantity, + i.name, + i.price, + i.deleted + FROM cart_item ci + LEFT JOIN item i ON ci.item_id = i.id + WHERE ci.cart_id = ? + """ + cursor = await db.execute(query, (id,)) + rows = await cursor.fetchall() + + items = [] + total_price = 0.0 + + for row in rows: + item = { + "id": row["item_id"], + "name": row["name"], + "quantity": row["quantity"], + "available": not bool(row["deleted"]) + } + items.append(item) + if not row["deleted"]: + total_price += row["price"] * row["quantity"] + + return { + "id": id, + "items": items, + "price": total_price + } + + +@api_router_cart.get('/', status_code=status.HTTP_200_OK) +async def get_carts_params( + offset: int = Query(0, ge=0, description="Смещение по списку"), + limit: int = Query(10, ge=1, le=100, description="Ограничение на количество"), + min_price: Optional[float] = Query(None, ge=0, description="Минимальная цена включительно"), + max_price: Optional[float] = Query(None, ge=0, description="Максимальная цена включительно"), + min_quantity: Optional[int] = Query(None, ge=0, description="Минимальное число товаров включительно"), + max_quantity: Optional[int] = Query(None, ge=0, description="Максимальное число товаров включительно"), + db: aiosqlite.Connection = Depends(get_conn) +): + query = """ + SELECT + c.id, + COALESCE(SUM(CASE WHEN i.deleted = 0 THEN ci.quantity * i.price ELSE 0 END), 0) as total_price, + SUM(ci.quantity) as total_quantity + FROM cart c + LEFT JOIN cart_item ci ON c.id = ci.cart_id + LEFT JOIN item i ON ci.item_id = i.id + GROUP BY c.id + """ + + conditions = [] + params = [] + + if min_price is not None: + conditions.append("total_price >= ?") + params.append(min_price) + + if max_price is not None: + conditions.append("total_price <= ?") + params.append(max_price) + + if min_quantity is not None: + conditions.append("total_quantity >= ?") + params.append(min_quantity) + + if max_quantity is not None: + conditions.append("total_quantity <= ?") + params.append(max_quantity) + + if conditions: + query += " HAVING " + " AND ".join(conditions) + + query += " ORDER BY c.id LIMIT ? OFFSET ?" + params.extend([limit, offset]) + + cursor = await db.execute(query, params) + rows = await cursor.fetchall() + + result = [] + for row in rows: + cart_id = row["id"] + + items_query = """ + SELECT + ci.item_id, + ci.quantity, + i.name, + i.price, + i.deleted + FROM cart_item ci + LEFT JOIN item i ON ci.item_id = i.id + WHERE ci.cart_id = ? + """ + items_cursor = await db.execute(items_query, (cart_id,)) + items_rows = await items_cursor.fetchall() + + items = [] + for item_row in items_rows: + items.append({ + "id": item_row["item_id"], + "name": item_row["name"], + "quantity": item_row["quantity"], + "available": not bool(item_row["deleted"]) + }) + + result.append({ + "id": cart_id, + "items": items, + "price": row["total_price"] + }) + + return result + + +@api_router_cart.post('/{cart_id}/add/{item_id}', status_code=status.HTTP_200_OK) +async def add_item_to_cart( + cart_id: int = Path(ge=1, description="ID корзины"), + item_id: int = Path(ge=1, description="ID товара"), + db: aiosqlite.Connection = Depends(get_conn) +): + cart_exists = await db.execute("SELECT id FROM cart WHERE id = ?", (cart_id,)) + if not await cart_exists.fetchone(): + + raise HTTPException(status_code=404, detail="Cart not found") + + item_exists = await db.execute("SELECT id FROM item WHERE id = ?", (item_id,)) + if not await item_exists.fetchone(): + + raise HTTPException(status_code=404, detail="Item not found") + + existing = await db.execute( + "SELECT quantity FROM cart_item WHERE cart_id = ? AND item_id = ?", + (cart_id, item_id) + ) + existing_row = await existing.fetchone() + + if existing_row: + await db.execute( + "UPDATE cart_item SET quantity = quantity + 1 WHERE cart_id = ? AND item_id = ?", + (cart_id, item_id) + ) + else: + await db.execute( + "INSERT INTO cart_item (cart_id, item_id, quantity) VALUES (?, ?, 1)", + (cart_id, item_id) + ) + + await db.commit() + return {"message": f"Item {item_id} added to cart {cart_id}"} + + +@api_router_item.post('/', status_code=status.HTTP_201_CREATED) +async def add_new_item( + item_data: dict, + db: aiosqlite.Connection = Depends(get_conn) +): + cursor = await db.execute( + "INSERT INTO item (name, price) VALUES (?, ?)", + (item_data["name"], item_data["price"]) + ) + await db.commit() + item_id = cursor.lastrowid + + row = await db.execute( + "SELECT id, name, price, deleted FROM item WHERE id = ?", + (item_id,) + ) + result = await row.fetchone() + return dict(result) + + +@api_router_item.get('/{id}', status_code=status.HTTP_200_OK) +async def get_item_by_id( + id: int = Path(ge=0, description="ID товара"), + db: aiosqlite.Connection = Depends(get_conn) +): + cursor = await db.execute( + "SELECT id, name, price, deleted FROM item WHERE id = ?", + (id,) + ) + result = await cursor.fetchone() + + if not result: + raise HTTPException(status_code=404, detail="Item not found") + + if result["deleted"]: + raise HTTPException(status_code=404, detail="Item not found") + + return dict(result) + + +@api_router_item.get('/', status_code=status.HTTP_200_OK) +async def get_items_params( + offset: int = Query(0, ge=0, description="Смещение по списку"), + limit: int = Query(10, ge=1, le=100, description="Ограничение на количество"), + show_deleted: Optional[bool] = Query(False, description="Показывать удаленные товары"), + min_price: Optional[float] = Query(None, ge=0, description="Минимальная цена включительно"), + max_price: Optional[float] = Query(None, ge=0, description="Максимальная цена включительно"), + db: aiosqlite.Connection = Depends(get_conn) +): + query = "SELECT id, name, price, deleted FROM item WHERE 1=1" + params = [] + + if not show_deleted: + query += " AND deleted = 0" + + if min_price is not None: + query += " AND price >= ?" + params.append(min_price) + + if max_price is not None: + query += " AND price <= ?" + params.append(max_price) + + query += " ORDER BY id LIMIT ? OFFSET ?" + params.extend([limit, offset]) + + cursor = await db.execute(query, params) + rows = await cursor.fetchall() + + return [dict(row) for row in rows] + + +@api_router_item.put('/{id}', status_code=status.HTTP_200_OK) +async def replace_item_by_id( + id: int = Path(ge=0, description='ID товара на замену'), + item_data: dict = None, + db: aiosqlite.Connection = Depends(get_conn) +): + if not item_data or "name" not in item_data or "price" not in item_data: + + raise HTTPException(status_code=422, detail="Missing required fields") + + cursor = await db.execute( + "SELECT id FROM item WHERE id = ?", + (id,) + ) + if not await cursor.fetchone(): + raise HTTPException(status_code=404, detail="Item not found") + + await db.execute( + "UPDATE item SET name = ?, price = ? WHERE id = ?", + (item_data["name"], item_data["price"], id) + ) + await db.commit() + + row = await db.execute( + "SELECT id, name, price, deleted FROM item WHERE id = ?", + (id,) + ) + result = await row.fetchone() + return dict(result) + + +@api_router_item.patch('/{id}', status_code=status.HTTP_200_OK) +async def edit_item_by_id( + id: int = Path(ge=0, description='ID товара для редактирования'), + item_data: dict = None, + db: aiosqlite.Connection = Depends(get_conn) +): + cursor = await db.execute( + "SELECT id, name, price, deleted FROM item WHERE id = ?", + (id,) + ) + result = await cursor.fetchone() + + if not result: + raise HTTPException(status_code=404, detail="Item not found") + + if result["deleted"]: + raise HTTPException(status_code=304, detail="Item is deleted") + + if not item_data: + return dict(result) + + if "deleted" in item_data: + raise HTTPException(status_code=422, detail="Cannot modify deleted field") + + possible_fields = {'name', 'price'} + for k in item_data.keys(): + if k not in possible_fields: + raise HTTPException(status_code=422, detail="Invalid field") + + update_fields = [] + params = [] + + if "name" in item_data: + update_fields.append("name = ?") + params.append(item_data["name"]) + + if "price" in item_data: + update_fields.append("price = ?") + params.append(item_data["price"]) + + if update_fields: + params.append(id) + await db.execute( + f"UPDATE item SET {', '.join(update_fields)} WHERE id = ?", + params + ) + await db.commit() + + row = await db.execute( + "SELECT id, name, price, deleted FROM item WHERE id = ?", + (id,) + ) + result = await row.fetchone() + + return dict(result) + + +@api_router_item.delete('/{id}', status_code=status.HTTP_200_OK) +async def delete_item_by_id( + id: int = Path(ge=0, description='ID товара для редактирования'), + db: aiosqlite.Connection = Depends(get_conn) +): + cursor = await db.execute( + "SELECT id, deleted FROM item WHERE id = ?", + (id,) + ) + + item = await cursor.fetchone() + if not item: + raise HTTPException(status_code=404, detail="Item not found") + + if item["deleted"] == 1: + return {"message": f"Item {id} already deleted"} + + await db.execute( + "UPDATE item SET deleted = 1 WHERE id = ?", + (id,) + ) + await db.commit() + + return {"message": f"Item {id} deleted"} + +app.include_router(api_router_cart) +app.include_router(api_router_item)