diff --git a/hw2/Dockerfile b/hw2/Dockerfile new file mode 100644 index 0000000..db576b4 --- /dev/null +++ b/hw2/Dockerfile @@ -0,0 +1,7 @@ +FROM python:3.12-alpine +WORKDIR /service +COPY hw/requirements.txt . +RUN pip install -r requirements.txt +COPY hw ./ +EXPOSE 8000 +ENTRYPOINT ["uvicorn", "shop_api.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/hw2/docker-compose.yml b/hw2/docker-compose.yml new file mode 100644 index 0000000..2bb7f9a --- /dev/null +++ b/hw2/docker-compose.yml @@ -0,0 +1,19 @@ +services: + app: + build: . + ports: + - "8000:8000" + prometheus: + image: prom/prometheus + ports: + - "9090:9090" + volumes: + - ./prometheus.yml:/etc/prometheus/prometheus.yml + depends_on: + - app + grafana: + image: grafana/grafana + ports: + - "3000:3000" + depends_on: + - prometheus \ No newline at end of file diff --git a/hw2/hw/requirements.txt b/hw2/hw/requirements.txt index 207dcf5..f695db8 100644 --- a/hw2/hw/requirements.txt +++ b/hw2/hw/requirements.txt @@ -7,3 +7,4 @@ pytest>=7.4.0 pytest-asyncio>=0.21.0 httpx>=0.27.2 Faker>=37.8.0 +prometheus-client \ No newline at end of file diff --git a/hw2/hw/shop_api/main.py b/hw2/hw/shop_api/main.py index f60a8c6..b0828bb 100644 --- a/hw2/hw/shop_api/main.py +++ b/hw2/hw/shop_api/main.py @@ -1,3 +1,310 @@ -from fastapi import FastAPI +from fastapi import FastAPI, Query, HTTPException +from typing import Any, Annotated +from http import HTTPStatus +from fastapi.responses import JSONResponse, Response +from prometheus_client import generate_latest app = FastAPI(title="Shop API") +@app.get("/metrics") +def metrics(): + return Response( + content=generate_latest(), + media_type="text/plain", + ) + +items: dict[int, dict[str, Any]] = {} +next_item_id = 1 + +carts: dict[int, dict[int, int]] = {} +next_cart_id = 1 + + +@app.post("/item") +def create_item(data: dict[str, Any]) -> JSONResponse: + global next_item_id + + if "name" not in data or "price" not in data: + raise HTTPException( + status_code=422, + detail="Item must contain name and price", + ) + + item = { + "id": next_item_id, + "name": data["name"], + "price": data["price"], + "deleted": False, + } + + items[next_item_id] = item + next_item_id += 1 + + return JSONResponse( + status_code=HTTPStatus.CREATED, + content=item, + ) + + +@app.get("/item/{id}") +def get_item(id: int) -> JSONResponse: + if id not in items or items[id]["deleted"]: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail="Item not found", + ) + + return JSONResponse( + status_code=HTTPStatus.OK, + content=items[id], + ) + + +@app.get("/item") +def get_items( + offset: Annotated[int,Query()] = 0, + limit: Annotated[int,Query()] = 10, + min_price: Annotated[float | None, Query()] = None, + max_price: Annotated[float | None, Query()] = None, + show_deleted: Annotated[bool, Query()] = False, +) -> JSONResponse: + if offset < 0: + raise HTTPException(status_code=422, detail="offset must be non-negative") + if limit <= 0: + raise HTTPException(status_code=422, detail="limit must be positive") + if min_price is not None and min_price < 0: + raise HTTPException(status_code=422, detail="min_price must be non-negative") + if max_price is not None and max_price < 0: + raise HTTPException(status_code=422, detail="max_price must be non-negative") + + result = [] + for item in items.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 = result[offset : offset + limit] + return JSONResponse( + status_code=HTTPStatus.OK, + content=result, + ) + + +@app.put("/item/{id}") +def put_item(id: int, data: dict[str, Any]) -> JSONResponse: + if id not in items or items[id]["deleted"]: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail="Item not found", + ) + if "name" not in data or "price" not in data: + raise HTTPException( + status_code=422, + detail="Item must contain name and price", + ) + items[id] = { + "id": id, + "name": data["name"], + "price": data["price"], + "deleted": False, + } + + return JSONResponse( + status_code=HTTPStatus.OK, + content=items[id], + ) + + +@app.patch("/item/{id}") +def patch_item(id: int, data: dict[str, Any]) -> JSONResponse: + if id not in items: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail="Item not found", + ) + if items[id]["deleted"]: + raise HTTPException( + status_code=304, + detail="Item is deleted", + ) + for key in data: + if key not in ("name", "price"): + raise HTTPException( + status_code=422, + detail="You can change only name and price", + ) + + if "name" in data: + items[id]["name"] = data["name"] + if "price" in data: + items[id]["price"] = data["price"] + + return JSONResponse( + status_code=HTTPStatus.OK, + content=items[id], + ) + +@app.delete("/item/{id}") +def delete_item(id: int) -> JSONResponse: + if id not in items: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail="Item not found", + ) + + items[id]["deleted"] = True + return JSONResponse( + status_code=HTTPStatus.OK, + content=items[id], + ) + + +@app.post("/cart") +def create_cart() -> JSONResponse: + global next_cart_id + new_cart_id = next_cart_id + carts[new_cart_id] = {} + next_cart_id += 1 + return JSONResponse( + status_code=HTTPStatus.CREATED, + content={"id": new_cart_id}, + headers={"location": f"/cart/{new_cart_id}"}, + ) + + +@app.post("/cart/{cart_id}/add/{item_id}") +def add_item_to_cart(cart_id: int, item_id: int) -> JSONResponse: + if cart_id not in carts: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail="Cart not found", + ) + if item_id not in items or items[item_id]["deleted"]: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail="Item not found", + ) + if item_id in carts[cart_id]: + carts[cart_id][item_id] += 1 + else: + carts[cart_id][item_id] = 1 + + return JSONResponse( + status_code=HTTPStatus.OK, + content={"cart_id": cart_id, "item_id": item_id}, + ) + +@app.get("/cart/{id}") +def get_cart(id: int) -> JSONResponse: + if id not in carts: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail="Cart not found", + ) + + cart_items = [] + total_price = 0 + for item_id, quantity in carts[id].items(): + item = items[item_id] + cart_items.append( + { + "id": item_id, + "name": item["name"], + "quantity": quantity, + "available": not item["deleted"], + } + ) + total_price += item["price"] * quantity + + cart = { + "id": id, + "items": cart_items, + "price": total_price, + } + return JSONResponse( + status_code=HTTPStatus.OK, + content=cart, + ) + +@app.get("/cart") +def get_carts( + offset: Annotated[int, Query()] = 0, + limit: Annotated[int, Query()] = 10, + min_price: Annotated[float | None, Query()] = None, + max_price: Annotated[float | None, Query()] = None, + min_quantity: Annotated[int | None, Query()] = None, + max_quantity: Annotated[int | None, Query()] = None, +) -> JSONResponse: + if offset < 0: + raise HTTPException( + status_code=422, + detail="offset must be non-negative", + ) + if limit <= 0: + raise HTTPException( + status_code=422, + detail="limit must be positive", + ) + if min_price is not None and min_price < 0: + raise HTTPException( + status_code=422, + detail="min_price must be non-negative", + ) + if max_price is not None and max_price < 0: + raise HTTPException( + status_code=422, + detail="max_price must be non-negative", + ) + if min_quantity is not None and min_quantity < 0: + raise HTTPException( + status_code=422, + detail="min_quantity must be non-negative", + ) + if max_quantity is not None and max_quantity < 0: + raise HTTPException( + status_code=422, + detail="max_quantity must be non-negative", + ) + + result = [] + for cart_id in carts: + cart_items = [] + total_price = 0 + total_quantity = 0 + for item_id, quantity in carts[cart_id].items(): + item = items[item_id] + cart_items.append( + { + "id": item_id, + "name": item["name"], + "quantity": quantity, + "available": not item["deleted"], + } + ) + total_price += item["price"] * quantity + total_quantity += quantity + + if min_price is not None and total_price < min_price: + continue + if max_price is not None and total_price > max_price: + continue + if min_quantity is not None and total_quantity < min_quantity: + continue + if max_quantity is not None and total_quantity > max_quantity: + continue + result.append( + { + "id": cart_id, + "items": cart_items, + "price": total_price, + } + ) + + result = result[offset : offset + limit] + return JSONResponse( + status_code=HTTPStatus.OK, + content=result, + ) \ No newline at end of file diff --git a/hw2/prometheus.yml b/hw2/prometheus.yml new file mode 100644 index 0000000..3daf20a --- /dev/null +++ b/hw2/prometheus.yml @@ -0,0 +1,7 @@ +global: + scrape_interval: 5s + +scrape_configs: + - job_name: "shop-api" + static_configs: + - targets: ["app:8000"] \ No newline at end of file