diff --git a/hw1/app.py b/hw1/app.py index 6107b87..225e694 100644 --- a/hw1/app.py +++ b/hw1/app.py @@ -1,6 +1,128 @@ +import json +from urllib.parse import parse_qs from typing import Any, Awaitable, Callable +BAD_REQUEST = None, 400 +UNPROCESSABLE_ENTITY = None, 422 +SUCCEFULL = lambda result: ({"result": result}, 200) + + +async def check_integer(n_str): + try: + n = int(n_str) + except (ValueError, TypeError): + return None + return n + + +async def fibonacci(request): + n = await check_integer(request['path_params']['n']) + if n is None: + return UNPROCESSABLE_ENTITY + if n < 0: + return BAD_REQUEST + def fib(n): + if n <= 1: + return n + a, b = 0, 1 + for _ in range(2, n + 1): + a, b = b, a + b + return b + result = fib(n) + return SUCCEFULL(result) + + +async def factorial(request): + query_params = request['query_params'] + if 'n' not in query_params: + return UNPROCESSABLE_ENTITY + n = await check_integer(query_params['n']) + if n is None: + return UNPROCESSABLE_ENTITY + if n < 0: + return BAD_REQUEST + result = 1 + for i in range(1, n + 1): + result *= i + return SUCCEFULL(result) + + +async def mean(request): + body = request.get('body') + if body is None or body == '': + return UNPROCESSABLE_ENTITY + try: + data = json.loads(body) + except (json.JSONDecodeError, TypeError): + return UNPROCESSABLE_ENTITY + if not isinstance(data, list): + return UNPROCESSABLE_ENTITY + if len(data) == 0: + return BAD_REQUEST + try: + numbers = [float(x) for x in data] + except (TypeError, ValueError): + return UNPROCESSABLE_ENTITY + result = sum(numbers) / len(numbers) + return SUCCEFULL(result) + + +async def send_json_response(send, status_code, data): + json_bytes = json.dumps(data, ensure_ascii=False).encode('utf-8') + await send({ + 'type': 'http.response.start', + 'status': status_code, + 'headers': [ + [b'content-type', b'application/json; charset=utf-8'], + [b'content-length', str(len(json_bytes)).encode('ascii')], + ], + }) + await send({ + 'type': 'http.response.body', + 'body': json_bytes, + }) + + +def parse_path(path): + path = path.rstrip('/') + parts = path.split('/') + if len(parts) == 3 and parts[1] == 'fibonacci': + return 'fibonacci', {'n': parts[2]} + if len(parts) == 2 and parts[1] == 'factorial': + return 'factorial', {} + if len(parts) == 2 and parts[1] == 'mean': + return 'mean', {} + return None, {} + + +async def read_body(receive): + body = b'' + while True: + message = await receive() + body += message.get('body', b'') + if not message.get('more_body', False): + break + return body + + +async def handle_lifespan(receive, send): + 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 + + +HANDLERS = { + 'fibonacci': fibonacci, + 'factorial': factorial, + 'mean': mean, +} + + async def application( scope: dict[str, Any], receive: Callable[[], Awaitable[dict[str, Any]]], @@ -12,7 +134,35 @@ async def application( receive: Корутина для получения сообщений от клиента send: Корутина для отправки сообщений клиенту """ - # TODO: Ваша реализация здесь + if scope["type"] == "lifespan": + await handle_lifespan(receive, send) + return + if scope["type"] != "http": + return + path = scope['path'] + method = scope['method'] + query_string = scope['query_string'] + query_params = {k: v[0] for k, v in parse_qs(query_string.decode('utf-8')).items()} + body_bytes = await read_body(receive) + body = body_bytes.decode('utf-8') if len(body_bytes) > 0 else None + endpoint, path_params = parse_path(path) + if endpoint is None: + await send_json_response(send, 404, {'error': 'Not Found'}) + return + request = { + 'path': path, + 'method': method, + 'query_params': query_params, + 'path_params': path_params, + 'body': body, + } + handler = HANDLERS[endpoint] + result, status_code = await handler(request) + if result is None: + await send_json_response(send, status_code, {'error': 'Invalid request'}) + else: + await send_json_response(send, status_code, result) + if __name__ == "__main__": import uvicorn diff --git a/hw2/hw/Dockerfile b/hw2/hw/Dockerfile new file mode 100644 index 0000000..08ae370 --- /dev/null +++ b/hw2/hw/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", "shop_api.main:app", "--port", "8080", "--host", "0.0.0.0"] diff --git a/hw2/hw/docker-compose.yml b/hw2/hw/docker-compose.yml new file mode 100644 index 0000000..91b5555 --- /dev/null +++ b/hw2/hw/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/hw2/hw/requirements.txt b/hw2/hw/requirements.txt index 207dcf5..af0702d 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 + +prometheus-fastapi-instrumentator \ No newline at end of file diff --git a/hw2/hw/screenshot/grafana_dashboards.jpg b/hw2/hw/screenshot/grafana_dashboards.jpg new file mode 100644 index 0000000..7908b8e Binary files /dev/null and b/hw2/hw/screenshot/grafana_dashboards.jpg differ diff --git a/hw2/hw/settings/prometheus/prometheus.yml b/hw2/hw/settings/prometheus/prometheus.yml new file mode 100644 index 0000000..7fa1951 --- /dev/null +++ b/hw2/hw/settings/prometheus/prometheus.yml @@ -0,0 +1,10 @@ +global: + scrape_interval: 10s + evaluation_interval: 10s + +scrape_configs: + - job_name: shop-api-local + metrics_path: /metrics + static_configs: + - targets: + - local:8080 diff --git a/hw2/hw/shop_api/main.py b/hw2/hw/shop_api/main.py index f60a8c6..35d98bd 100644 --- a/hw2/hw/shop_api/main.py +++ b/hw2/hw/shop_api/main.py @@ -1,3 +1,203 @@ -from fastapi import FastAPI +from fastapi import FastAPI, HTTPException, Query, Response +from prometheus_fastapi_instrumentator import Instrumentator +from pydantic import BaseModel, ConfigDict +from typing import Optional, List + app = FastAPI(title="Shop API") +Instrumentator().instrument(app).expose(app) + +class ItemBase(BaseModel): + name: str + price: float + +class ItemCreate(ItemBase): + pass + +class ItemUpdate(BaseModel): + model_config = ConfigDict(extra='forbid') + + name: Optional[str] = None + price: Optional[float] = None + deleted: Optional[bool] = None + +class Item(ItemBase): + id: int + deleted: bool = False + +class CartItem(BaseModel): + id: int + name: str + quantity: int + available: bool + +class Cart(BaseModel): + id: int + items: List[CartItem] + price: float + +class CartIdResponse(BaseModel): + id: int + +items_db: dict[int, Item] = {} +carts_db: dict[int, Cart] = {} + +item_id_cnt = 0 +cart_id_cnt = 0 + +ITEM_NOT_FOUND = "Item not found" +CART_NOT_FOUND = "Cart not found" + + +def calculate_cart(cart: Cart) -> Cart: + total_price = 0.0 + updated_items = [] + for cart_item in cart.items: + db_item = items_db.get(cart_item.id) + if db_item: + is_available = not db_item.deleted + total_price += db_item.price * cart_item.quantity + updated_items.append(CartItem( + id=cart_item.id, + name=db_item.name, + quantity=cart_item.quantity, + available=is_available + )) + return Cart( + id=cart.id, + items=updated_items, + price=total_price + ) + + +@app.post("/item", response_model=Item, status_code=201) +def create_item(item: ItemCreate): + global item_id_cnt + new_item = Item(id=item_id_cnt, name=item.name, price=item.price, deleted=False) + items_db[item_id_cnt] = new_item + item_id_cnt += 1 + return new_item + + +@app.get("/item/{item_id}", response_model=Item) +def get_item(item_id: int): + if item_id not in items_db or items_db[item_id].deleted: + raise HTTPException(status_code=404, detail=ITEM_NOT_FOUND) + return items_db[item_id] + + +@app.get("/item", response_model=List[Item]) +def list_items( + offset: int = Query(0, ge=0), + limit: int = Query(10, gt=0), + min_price: Optional[float] = Query(None, ge=0), + max_price: Optional[float] = Query(None, ge=0), + show_deleted: bool = False +): + result = [] + for item in items_db.values(): + if not show_deleted and item.deleted: + continue + if min_price is not None and item.price < min_price: + continue + if max_price is not None and item.price > max_price: + continue + result.append(item) + result.sort(key=lambda x: x.id) + return result[offset:offset + limit] + + +@app.put("/item/{item_id}", response_model=Item) +def update_item(item_id: int, item: ItemCreate): + if item_id not in items_db: + raise HTTPException(status_code=404, detail=ITEM_NOT_FOUND) + items_db[item_id].name = item.name + items_db[item_id].price = item.price + items_db[item_id].deleted = False + return items_db[item_id] + + +@app.patch("/item/{item_id}", response_model=Item) +def patch_item(item_id: int, item: ItemUpdate): + if item_id not in items_db or items_db[item_id].deleted: + raise HTTPException(status_code=304, detail="Not modified") + if item.deleted is not None: + raise HTTPException(status_code=422, detail="Cannot update 'deleted' field via PATCH") + if item.name is not None: + items_db[item_id].name = item.name + if item.price is not None: + items_db[item_id].price = item.price + return items_db[item_id] + + +@app.delete("/item/{item_id}", response_model=Item) +def delete_item(item_id: int): + if item_id not in items_db: + raise HTTPException(status_code=404, detail=ITEM_NOT_FOUND) + items_db[item_id].deleted = True + return items_db[item_id] + + +@app.post("/cart", response_model=CartIdResponse, status_code=201) +def create_cart(response: Response): + global cart_id_cnt + new_id = cart_id_cnt + carts_db[new_id] = Cart(id=new_id, items=[], price=0.0) + cart_id_cnt += 1 + response.headers["Location"] = f"/cart/{new_id}" + return {"id": new_id} + + +@app.get("/cart/{cart_id}", response_model=Cart) +def get_cart(cart_id: int): + if cart_id not in carts_db: + raise HTTPException(status_code=404, detail=CART_NOT_FOUND) + return calculate_cart(carts_db[cart_id]) + + +@app.get("/cart", response_model=List[Cart]) +def list_carts( + offset: int = Query(0, ge=0), + limit: int = Query(10, gt=0), + min_price: Optional[float] = Query(None, ge=0), + max_price: Optional[float] = Query(None, ge=0), + min_quantity: Optional[int] = Query(None, ge=0), + max_quantity: Optional[int] = Query(None, ge=0) +): + result = [] + for cart in carts_db.values(): + calc_cart = calculate_cart(cart) + if min_price is not None and calc_cart.price < min_price: + continue + if max_price is not None and calc_cart.price > max_price: + continue + total_qty = sum(ci.quantity for ci in calc_cart.items) + if min_quantity is not None and total_qty < min_quantity: + continue + if max_quantity is not None and total_qty > max_quantity: + continue + result.append(calc_cart) + result.sort(key=lambda x: x.id) + return result[offset:offset + limit] + + +@app.post("/cart/{cart_id}/add/{item_id}", response_model=Cart) +def add_to_cart(cart_id: int, item_id: int): + if cart_id not in carts_db: + raise HTTPException(status_code=404, detail=CART_NOT_FOUND) + if item_id not in items_db: + raise HTTPException(status_code=404, detail=ITEM_NOT_FOUND) + cart = carts_db[cart_id] + db_item = items_db[item_id] + for cart_item in cart.items: + if cart_item.id == item_id: + cart_item.quantity += 1 + return calculate_cart(cart) + new_cart_item = CartItem( + id=db_item.id, + name=db_item.name, + quantity=1, + available=not db_item.deleted + ) + cart.items.append(new_cart_item) + return calculate_cart(cart)