diff --git a/hw1/app.py b/hw1/app.py index 6107b870..2c38fecf 100644 --- a/hw1/app.py +++ b/hw1/app.py @@ -1,6 +1,39 @@ +import ast +import http.client +import math from typing import Any, Awaitable, Callable +async def compute_fibonacci(num: int) -> int: + start = [0, 1] + for i in range(2, num + 1): + start.append(start[i - 1] + start[i - 2]) + return start[num] + + +async def compute_factorial(num: int) -> int: + return math.factorial(num) + + +async def compute_mean(values: list[float]) -> float: + return sum(values) / len(values) + + +async def send_error(send: Callable[[dict[str, Any]], Awaitable[None]], status: int) -> None: + await send({"type": "http.response.start", "status": status, "headers": []}) + await send({"type": "http.response.body", "body": b"Error"}) + + +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"}) + break + + async def application( scope: dict[str, Any], receive: Callable[[], Awaitable[dict[str, Any]]], @@ -12,7 +45,73 @@ async def application( receive: Корутина для получения сообщений от клиента send: Корутина для отправки сообщений клиенту """ - # TODO: Ваша реализация здесь + if scope["type"] == "lifespan": + await handle_lifespan(receive, send) + + if scope["path"].startswith("/fibonacci/"): + await receive() + path: str = scope["path"] + path = path.rpartition("/")[2] + try: + num = int(path) + except ValueError: + await send_error(send, http.client.UNPROCESSABLE_ENTITY) + return + if num < 0: + await send_error(send, http.client.BAD_REQUEST) + return + result = await compute_fibonacci(num) + await send({"type": "http.response.start", "status": 200, "headers": [[b"content-type", b"application/json"]]}) + await send({"type": "http.response.body", "body": f'{{"result": {result}}}'.encode()}) + return + + if scope["path"] == "/factorial": + await receive() + query_string = scope["query_string"].decode() + if not query_string or "n=" not in query_string: + await send_error(send, http.client.UNPROCESSABLE_ENTITY) + return + num_str = query_string.rpartition("=")[2] + if not num_str: + await send_error(send, http.client.UNPROCESSABLE_ENTITY) + return + try: + num = int(num_str) + except ValueError: + await send_error(send, http.client.UNPROCESSABLE_ENTITY) + return + if num < 0: + await send_error(send, http.client.BAD_REQUEST) + return + result = await compute_factorial(num) + await send({"type": "http.response.start", "status": 200, "headers": [[b"content-type", b"application/json"]]}) + await send({"type": "http.response.body", "body": f'{{"result": {result}}}'.encode()}) + return + + if scope["path"] == "/mean": + body = await receive() + body_str = body["body"].decode() + if not body_str: + await send_error(send, http.client.UNPROCESSABLE_ENTITY) + return + try: + lst = ast.literal_eval(body_str) + except (ValueError, SyntaxError): + await send_error(send, http.client.UNPROCESSABLE_ENTITY) + return + if not isinstance(lst, list): + await send_error(send, http.client.UNPROCESSABLE_ENTITY) + return + if len(lst) == 0: + await send_error(send, http.client.BAD_REQUEST) + return + result = await compute_mean(lst) + await send({"type": "http.response.start", "status": 200, "headers": [[b"content-type", b"application/json"]]}) + await send({"type": "http.response.body", "body": f'{{"result": {result}}}'.encode()}) + return + await receive() + await send_error(send, http.client.NOT_FOUND) + if __name__ == "__main__": import uvicorn diff --git a/hw2/hw/shop_api/api/__init__.py b/hw2/hw/shop_api/api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hw2/hw/shop_api/api/cart_routes.py b/hw2/hw/shop_api/api/cart_routes.py new file mode 100644 index 00000000..3e2d2e70 --- /dev/null +++ b/hw2/hw/shop_api/api/cart_routes.py @@ -0,0 +1,117 @@ +from http import HTTPStatus +from typing import Annotated + +from fastapi import APIRouter, HTTPException, Query, Response +from pydantic import NonNegativeFloat, NonNegativeInt, PositiveInt + +from shop_api.store.queries import ( + add_cart, + add_item_to_cart, + get_cart, + get_carts, + get_item, +) + +from .contracts import CartIdResponse, CartResponse + +router = APIRouter(prefix="/cart") + + +@router.post( + "", + status_code=HTTPStatus.CREATED, +) +async def post_cart(response: Response) -> CartIdResponse: + entity = add_cart() + response.headers["location"] = f"/cart/{entity.id}" + return CartIdResponse(id=entity.id) + + +@router.get( + "/{id}", + responses={ + HTTPStatus.OK: { + "description": "Successfully returned requested cart", + }, + HTTPStatus.NOT_FOUND: { + "description": "Cart not found", + }, + }, +) +async def get_cart_by_id(id: int) -> CartResponse: + entity = get_cart(id) + + if not entity: + raise HTTPException( + HTTPStatus.NOT_FOUND, + f"Cart with id={id} not found", + ) + + items_dict = {} + for cart_item in entity.info.items: + item_entity = get_item(cart_item.id, include_deleted=True) + if item_entity: + items_dict[cart_item.id] = item_entity + + return CartResponse.from_entity(entity, items_dict) + + +@router.get("") +async def get_cart_list( + offset: Annotated[NonNegativeInt, Query()] = 0, + limit: Annotated[PositiveInt, Query()] = 10, + min_price: Annotated[NonNegativeFloat | None, Query()] = None, + max_price: Annotated[NonNegativeFloat | None, Query()] = None, + min_quantity: Annotated[NonNegativeInt | None, Query()] = None, + max_quantity: Annotated[NonNegativeInt | None, Query()] = None, +) -> list[CartResponse]: + entities = get_carts( + offset=offset, + limit=limit, + min_price=min_price, + max_price=max_price, + min_quantity=min_quantity, + max_quantity=max_quantity, + ) + + all_item_ids = set() + for cart_entity in entities: + for cart_item in cart_entity.info.items: + all_item_ids.add(cart_item.id) + + items_dict = {} + for item_id in all_item_ids: + item_entity = get_item(item_id, include_deleted=True) + if item_entity: + items_dict[item_id] = item_entity + + return [CartResponse.from_entity(e, items_dict) for e in entities] + + +@router.post( + "/{cart_id}/add/{item_id}", + responses={ + HTTPStatus.OK: { + "description": "Successfully added item to cart", + }, + HTTPStatus.NOT_FOUND: { + "description": "Cart or item not found", + }, + }, +) +async def add_item_to_cart_handler(cart_id: int, item_id: int) -> CartResponse: + entity = add_item_to_cart(cart_id, item_id) + + if not entity: + raise HTTPException( + HTTPStatus.NOT_FOUND, + f"Cart with id={cart_id} or item with id={item_id} not found", + ) + + items_dict = {} + for cart_item in entity.info.items: + item_entity = get_item(cart_item.id, include_deleted=True) + if item_entity: + items_dict[cart_item.id] = item_entity + + return CartResponse.from_entity(entity, items_dict) diff --git a/hw2/hw/shop_api/api/chat_routes.py b/hw2/hw/shop_api/api/chat_routes.py new file mode 100644 index 00000000..a8631b3a --- /dev/null +++ b/hw2/hw/shop_api/api/chat_routes.py @@ -0,0 +1,19 @@ +from fastapi import APIRouter, WebSocket, WebSocketDisconnect + +from shop_api.websocket_chat import chat_manager + +router = APIRouter() + + +@router.websocket("/chat/{chat_name}") +async def chat_endpoint(websocket: WebSocket, chat_name: str): + room = chat_manager.get_room(chat_name) + await room.connect(websocket) + + try: + while True: + message = await websocket.receive_text() + await room.broadcast(message, websocket) + except WebSocketDisconnect: + await room.disconnect(websocket) + diff --git a/hw2/hw/shop_api/api/contracts.py b/hw2/hw/shop_api/api/contracts.py new file mode 100644 index 00000000..5d658020 --- /dev/null +++ b/hw2/hw/shop_api/api/contracts.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict, NonNegativeFloat + +from shop_api.store.models import ( + CartEntity, + CartItemInfo, + ItemEntity, + ItemInfo, + PatchItemInfo, +) + + +class ItemResponse(BaseModel): + id: int + name: str + price: float + deleted: bool = False + + @staticmethod + def from_entity(entity: ItemEntity) -> ItemResponse: + return ItemResponse( + id=entity.id, + name=entity.info.name, + price=entity.info.price, + deleted=entity.info.deleted, + ) + + +class ItemRequest(BaseModel): + name: str + price: NonNegativeFloat + + def as_item_info(self) -> ItemInfo: + return ItemInfo(name=self.name, price=self.price, deleted=False) + + +class PatchItemRequest(BaseModel): + name: str | None = None + price: NonNegativeFloat | None = None + + model_config = ConfigDict(extra="forbid") + + def as_patch_item_info(self) -> PatchItemInfo: + return PatchItemInfo(name=self.name, price=self.price) + + +class CartItemResponse(BaseModel): + id: int + name: str + quantity: int + available: bool + + @staticmethod + def from_cart_item_info(info: CartItemInfo) -> CartItemResponse: + return CartItemResponse( + id=info.id, + name=info.name, + quantity=info.quantity, + available=info.available, + ) + + +class CartResponse(BaseModel): + id: int + items: list[CartItemResponse] + price: float + + @staticmethod + def from_entity(entity: CartEntity, items_dict: dict[int, ItemEntity]) -> CartResponse: + cart_items = [ + CartItemResponse.from_cart_item_info(item) + for item in entity.info.items + ] + + total_price = 0.0 + for item in entity.info.items: + if item.id in items_dict and item.available: + total_price += items_dict[item.id].info.price * item.quantity + + return CartResponse( + id=entity.id, + items=cart_items, + price=total_price, + ) + + +class CartIdResponse(BaseModel): + id: int diff --git a/hw2/hw/shop_api/api/item_routes.py b/hw2/hw/shop_api/api/item_routes.py new file mode 100644 index 00000000..962e7f5f --- /dev/null +++ b/hw2/hw/shop_api/api/item_routes.py @@ -0,0 +1,122 @@ +from http import HTTPStatus +from typing import Annotated + +from fastapi import APIRouter, HTTPException, Query, Response +from pydantic import NonNegativeFloat, NonNegativeInt, PositiveInt + +from shop_api.store.queries import ( + add_item, + delete_item, + get_item, + get_items, + patch_item, + update_item, +) + +from .contracts import ItemRequest, ItemResponse, PatchItemRequest + +router = APIRouter(prefix="/item") + + +@router.post( + "", + status_code=HTTPStatus.CREATED, +) +async def post_item(item_data: ItemRequest, response: Response) -> ItemResponse: + entity = add_item(item_data.as_item_info()) + response.headers["location"] = f"/item/{entity.id}" + return ItemResponse.from_entity(entity) + + +@router.get( + "/{id}", + responses={ + HTTPStatus.OK: { + "description": "Successfully returned requested item", + }, + HTTPStatus.NOT_FOUND: { + "description": "Item not found", + }, + }, +) +async def get_item_by_id(id: int) -> ItemResponse: + entity = get_item(id, include_deleted=False) + + if not entity: + raise HTTPException( + HTTPStatus.NOT_FOUND, + f"Item with id={id} not found", + ) + + return ItemResponse.from_entity(entity) + + +@router.get("") +async def get_item_list( + offset: Annotated[NonNegativeInt, Query()] = 0, + limit: Annotated[PositiveInt, Query()] = 10, + min_price: Annotated[NonNegativeFloat | None, Query()] = None, + max_price: Annotated[NonNegativeFloat | None, Query()] = None, + show_deleted: Annotated[bool, Query()] = False, +) -> list[ItemResponse]: + entities = get_items( + offset=offset, + limit=limit, + min_price=min_price, + max_price=max_price, + show_deleted=show_deleted, + ) + + return [ItemResponse.from_entity(e) for e in entities] + + +@router.put( + "/{id}", + responses={ + HTTPStatus.OK: { + "description": "Successfully updated item", + }, + HTTPStatus.NOT_MODIFIED: { + "description": "Item not found", + }, + }, +) +async def put_item(id: int, item_data: ItemRequest) -> ItemResponse: + entity = update_item(id, item_data.as_item_info()) + + if entity is None: + raise HTTPException( + HTTPStatus.NOT_MODIFIED, + f"Item with id={id} not found", + ) + + return ItemResponse.from_entity(entity) + + +@router.patch( + "/{id}", + responses={ + HTTPStatus.OK: { + "description": "Successfully patched item", + }, + HTTPStatus.NOT_MODIFIED: { + "description": "Item not found or deleted", + }, + }, +) +async def patch_item_handler(id: int, patch_data: PatchItemRequest) -> ItemResponse: + entity = patch_item(id, patch_data.as_patch_item_info()) + + if entity is None: + raise HTTPException( + HTTPStatus.NOT_MODIFIED, + f"Item with id={id} not found or is deleted", + ) + + return ItemResponse.from_entity(entity) + + +@router.delete("/{id}") +async def delete_item_handler(id: int) -> Response: + delete_item(id) + return Response(status_code=HTTPStatus.OK) diff --git a/hw2/hw/shop_api/main.py b/hw2/hw/shop_api/main.py index f60a8c60..16366e67 100644 --- a/hw2/hw/shop_api/main.py +++ b/hw2/hw/shop_api/main.py @@ -1,3 +1,11 @@ from fastapi import FastAPI +from shop_api.api.cart_routes import router as cart_router +from shop_api.api.chat_routes import router as chat_router +from shop_api.api.item_routes import router as item_router + app = FastAPI(title="Shop API") + +app.include_router(item_router) +app.include_router(cart_router) +app.include_router(chat_router) diff --git a/hw2/hw/shop_api/store/__init__.py b/hw2/hw/shop_api/store/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hw2/hw/shop_api/store/models.py b/hw2/hw/shop_api/store/models.py new file mode 100644 index 00000000..b44a6bbb --- /dev/null +++ b/hw2/hw/shop_api/store/models.py @@ -0,0 +1,33 @@ +from pydantic import BaseModel + + +class ItemInfo(BaseModel): + name: str + price: float + deleted: bool = False + + +class ItemEntity(BaseModel): + id: int + info: ItemInfo + + +class PatchItemInfo(BaseModel): + name: str | None = None + price: float | None = None + + +class CartItemInfo(BaseModel): + id: int + name: str + quantity: int + available: bool + + +class CartInfo(BaseModel): + items: list[CartItemInfo] = [] + + +class CartEntity(BaseModel): + id: int + info: CartInfo diff --git a/hw2/hw/shop_api/store/queries.py b/hw2/hw/shop_api/store/queries.py new file mode 100644 index 00000000..51545c9f --- /dev/null +++ b/hw2/hw/shop_api/store/queries.py @@ -0,0 +1,172 @@ +from typing import Iterable + +from .models import ( + CartEntity, + CartInfo, + CartItemInfo, + ItemEntity, + ItemInfo, + PatchItemInfo, +) + +_items = dict[int, ItemInfo]() +_carts = dict[int, CartInfo]() + + +def int_id_generator() -> Iterable[int]: + i = 0 + while True: + yield i + i += 1 + + +_item_id_generator = int_id_generator() +_cart_id_generator = int_id_generator() + + +def add_item(info: ItemInfo) -> ItemEntity: + _id = next(_item_id_generator) + _items[_id] = info + return ItemEntity(id=_id, info=info) + + +def get_item(item_id: int, include_deleted: bool = False) -> ItemEntity | None: + if item_id not in _items: + return None + + info = _items[item_id] + if not include_deleted and info.deleted: + return None + + return ItemEntity(id=item_id, info=info) + + +def get_items( + offset: int = 0, + limit: int = 10, + min_price: float | None = None, + max_price: float | None = None, + show_deleted: bool = False, +) -> list[ItemEntity]: + result = [] + + for item_id, info in _items.items(): + if not show_deleted and info.deleted: + continue + + if min_price is not None and info.price < min_price: + continue + if max_price is not None and info.price > max_price: + continue + + result.append(ItemEntity(id=item_id, info=info)) + + return result[offset:offset + limit] + + +def update_item(item_id: int, info: ItemInfo) -> ItemEntity | None: + if item_id not in _items: + return None + + _items[item_id] = info + return ItemEntity(id=item_id, info=info) + + +def patch_item(item_id: int, patch_info: PatchItemInfo) -> ItemEntity | None: + if item_id not in _items: + return None + + info = _items[item_id] + + if info.deleted: + return None + + if patch_info.name is not None: + info.name = patch_info.name + + if patch_info.price is not None: + info.price = patch_info.price + + return ItemEntity(id=item_id, info=info) + + +def delete_item(item_id: int) -> None: + if item_id in _items: + _items[item_id].deleted = True + + +def add_cart() -> CartEntity: + _id = next(_cart_id_generator) + info = CartInfo(items=[]) + _carts[_id] = info + return CartEntity(id=_id, info=info) + + +def get_cart(cart_id: int) -> CartEntity | None: + if cart_id not in _carts: + return None + + return CartEntity(id=cart_id, info=_carts[cart_id]) + + +def get_carts( + offset: int = 0, + limit: int = 10, + min_price: float | None = None, + max_price: float | None = None, + min_quantity: int | None = None, + max_quantity: int | None = None, +) -> list[CartEntity]: + result = [] + + for cart_id, info in _carts.items(): + total_price = 0.0 + total_quantity = 0 + + for cart_item in info.items: + total_quantity += cart_item.quantity + + item_entity = get_item(cart_item.id, include_deleted=True) + if item_entity and cart_item.available: + total_price += item_entity.info.price * cart_item.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(CartEntity(id=cart_id, info=info)) + + return result[offset:offset + limit] + + +def add_item_to_cart(cart_id: int, item_id: int) -> CartEntity | None: + if cart_id not in _carts: + return None + + cart_info = _carts[cart_id] + + item_entity = get_item(item_id, include_deleted=True) + if not item_entity: + return None + + for cart_item in cart_info.items: + if cart_item.id == item_id: + cart_item.quantity += 1 + cart_item.available = not item_entity.info.deleted + return CartEntity(id=cart_id, info=cart_info) + + cart_item = CartItemInfo( + id=item_id, + name=item_entity.info.name, + quantity=1, + available=not item_entity.info.deleted, + ) + cart_info.items.append(cart_item) + + return CartEntity(id=cart_id, info=cart_info) diff --git a/hw2/hw/shop_api/websocket_chat.py b/hw2/hw/shop_api/websocket_chat.py new file mode 100644 index 00000000..2646360b --- /dev/null +++ b/hw2/hw/shop_api/websocket_chat.py @@ -0,0 +1,55 @@ +import random +from collections import defaultdict + +from fastapi import WebSocket + + +class ChatRoom: + def __init__(self): + self.connections: dict[WebSocket, str] = {} + + async def connect(self, ws: WebSocket) -> str: + await ws.accept() + username = self._generate_username() + self.connections[ws] = username + return username + + async def disconnect(self, ws: WebSocket) -> None: + if ws in self.connections: + del self.connections[ws] + + async def broadcast(self, message: str, sender_ws: WebSocket) -> None: + username = self.connections.get(sender_ws, "Unknown") + formatted_message = f"{username} :: {message}" + + for ws in self.connections: + await ws.send_text(formatted_message) + + def _generate_username(self) -> str: + adjectives = [ + "Happy", "Brave", "Calm", "Dreamy", "Eager", + "Fancy", "Gentle", "Jolly", "Kind", "Lively", + "Mighty", "Nice", "Proud", "Quick", "Smart", + "Swift", "Wise", "Witty", "Young", "Zesty" + ] + + nouns = [ + "Panda", "Tiger", "Eagle", "Dolphin", "Fox", + "Wolf", "Bear", "Lion", "Owl", "Hawk", + "Dragon", "Phoenix", "Unicorn", "Griffin", "Raven", + "Falcon", "Panther", "Lynx", "Otter", "Shark" + ] + + return f"{random.choice(adjectives)}{random.choice(nouns)}" + + +class ChatManager: + def __init__(self): + self.rooms: dict[str, ChatRoom] = defaultdict(ChatRoom) + + def get_room(self, chat_name: str) -> ChatRoom: + return self.rooms[chat_name] + + +chat_manager = ChatManager() + diff --git a/lecture4/.gitignore b/lecture4/.gitignore new file mode 100644 index 00000000..79f7a6f7 --- /dev/null +++ b/lecture4/.gitignore @@ -0,0 +1,5 @@ +vendor +docs +internal/repository/db.go +internal/repository/models.go +internal/repository/queries.sql.go diff --git a/lecture4/Dockerfile b/lecture4/Dockerfile new file mode 100644 index 00000000..06ff8ef9 --- /dev/null +++ b/lecture4/Dockerfile @@ -0,0 +1,29 @@ +FROM golang:1.24-alpine AS builder + +WORKDIR /app + +COPY go.mod ./ +RUN go mod download + +COPY . . + +RUN go run github.com/sqlc-dev/sqlc/cmd/sqlc@latest generate + +RUN go run github.com/swaggo/swag/cmd/swag@latest init -g cmd/app/main.go + +RUN go mod tidy + +RUN CGO_ENABLED=0 GOOS=linux go build -o main ./cmd/app + +FROM alpine:latest + +WORKDIR /app + +RUN apk --no-cache add ca-certificates + +COPY --from=builder /app/main . + +EXPOSE 8080 + +CMD ["./main"] + diff --git a/lecture4/cmd/app/main.go b/lecture4/cmd/app/main.go new file mode 100644 index 00000000..86153e06 --- /dev/null +++ b/lecture4/cmd/app/main.go @@ -0,0 +1,72 @@ +package main + +import ( + "database/sql" + "log" + "time" + + "github.com/gin-gonic/gin" + _ "github.com/lib/pq" + "github.com/prometheus/client_golang/prometheus/promhttp" + swaggerFiles "github.com/swaggo/files" + ginSwagger "github.com/swaggo/gin-swagger" + "lecture4/internal/api" + "lecture4/internal/config" + + _ "lecture4/docs" +) + +// @title Shop API +// @version 1.0 +// @description REST API для интернет магазина с товарами и корзинами +// @host localhost:8080 +// @BasePath / +func main() { + cfg := config.Load() + + // Подключение к базе данных с retry логикой + var db *sql.DB + var err error + for i := 0; i < 10; i++ { + db, err = sql.Open("postgres", cfg.DatabaseURL) + if err == nil { + err = db.Ping() + if err == nil { + break + } + } + log.Printf("Failed to connect to database, retrying in 2 seconds... (%d/10)", i+1) + time.Sleep(2 * time.Second) + } + if err != nil { + log.Fatalf("Failed to connect to database after 10 attempts: %v", err) + } + defer db.Close() + + log.Println("Successfully connected to database") + + router := gin.Default() + + router.GET("/metrics", gin.WrapH(promhttp.Handler())) + + router.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler)) + + handler := api.NewHandler(db) + + router.POST("/item", handler.PostItem) + router.GET("/item/:id", handler.GetItemByID) + router.GET("/item", handler.GetItems) + router.PUT("/item/:id", handler.PutItem) + router.PATCH("/item/:id", handler.PatchItem) + router.DELETE("/item/:id", handler.DeleteItem) + + router.POST("/cart", handler.PostCart) + router.GET("/cart/:id", handler.GetCartByID) + router.GET("/cart", handler.GetCarts) + router.POST("/cart/:cart_id/add/:item_id", handler.AddItemToCart) + + log.Printf("Starting server on port %s", cfg.Port) + if err := router.Run(":" + cfg.Port); err != nil { + log.Fatalf("Failed to start server: %v", err) + } +} diff --git a/lecture4/ddoser.py b/lecture4/ddoser.py new file mode 100644 index 00000000..b238885e --- /dev/null +++ b/lecture4/ddoser.py @@ -0,0 +1,157 @@ +from concurrent.futures import ThreadPoolExecutor, as_completed + +import requests +from faker import Faker + +faker = Faker() + + +def create_items(): + """Создает товары в магазине""" + for _ in range(500): + product_name = faker.word().capitalize() + " " + faker.word() + price = round(faker.random.uniform(10.0, 9999.99), 2) + + response = requests.post( + "http://localhost:8080/item", + json={ + "name": product_name, + "price": price, + }, + ) + print(f"Create item: {response.status_code}") + + +def get_items(): + """Получает список товаров""" + for _ in range(500): + offset = faker.random_int(0, 50) + limit = faker.random_int(5, 50) + + response = requests.get( + "http://localhost:8080/item", + params={ + "offset": offset, + "limit": limit, + }, + ) + print(f"Get items: {response.status_code}") + + +def get_item_by_id(): + """Получает товар по ID""" + for _ in range(500): + item_id = faker.random_int(1, 100) + + response = requests.get( + f"http://localhost:8080/item/{item_id}", + ) + print(f"Get item {item_id}: {response.status_code}") + + +def update_items(): + """Обновляет товары""" + for _ in range(300): + item_id = faker.random_int(1, 50) + product_name = faker.word().capitalize() + " Updated" + price = round(faker.random.uniform(10.0, 9999.99), 2) + + response = requests.put( + f"http://localhost:8080/item/{item_id}", + json={ + "name": product_name, + "price": price, + }, + ) + print(f"Update item {item_id}: {response.status_code}") + + +def create_carts(): + """Создает корзины""" + for _ in range(300): + response = requests.post( + "http://localhost:8080/cart", + ) + print(f"Create cart: {response.status_code}") + + +def get_carts(): + """Получает список корзин""" + for _ in range(300): + offset = faker.random_int(0, 50) + limit = faker.random_int(5, 30) + + response = requests.get( + "http://localhost:8080/cart", + params={ + "offset": offset, + "limit": limit, + }, + ) + print(f"Get carts: {response.status_code}") + + +def add_items_to_cart(): + """Добавляет товары в корзины""" + for _ in range(400): + cart_id = faker.random_int(1, 100) + item_id = faker.random_int(1, 100) + + response = requests.post( + f"http://localhost:8080/cart/{cart_id}/add/{item_id}", + ) + print(f"Add item {item_id} to cart {cart_id}: {response.status_code}") + + +def get_cart_by_id(): + """Получает корзину по ID""" + for _ in range(400): + cart_id = faker.random_int(1, 100) + + response = requests.get( + f"http://localhost:8080/cart/{cart_id}", + ) + print(f"Get cart {cart_id}: {response.status_code}") + + +if __name__ == "__main__": + with ThreadPoolExecutor(max_workers=20) as executor: + futures = {} + + # Создание товаров + for i in range(10): + futures[executor.submit(create_items)] = f"create-items-{i}" + + # Получение списков товаров + for i in range(10): + futures[executor.submit(get_items)] = f"get-items-{i}" + + # Получение товаров по ID + for i in range(10): + futures[executor.submit(get_item_by_id)] = f"get-item-by-id-{i}" + + # Обновление товаров + for i in range(5): + futures[executor.submit(update_items)] = f"update-items-{i}" + + # Создание корзин + for i in range(8): + futures[executor.submit(create_carts)] = f"create-carts-{i}" + + # Получение списков корзин + for i in range(8): + futures[executor.submit(get_carts)] = f"get-carts-{i}" + + # Добавление товаров в корзины + for i in range(12): + futures[executor.submit(add_items_to_cart)] = f"add-items-to-cart-{i}" + + # Получение корзин по ID + for i in range(12): + futures[executor.submit(get_cart_by_id)] = f"get-cart-by-id-{i}" + + for future in as_completed(futures): + print(f"✓ Completed {futures[future]}") + + print("\n🎯 DDoS test completed!") + diff --git a/lecture4/docker-compose.shop.yml b/lecture4/docker-compose.shop.yml new file mode 100644 index 00000000..efa57a00 --- /dev/null +++ b/lecture4/docker-compose.shop.yml @@ -0,0 +1,61 @@ +services: + postgres: + build: + context: ./migrations + dockerfile: Dockerfile + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: shop + volumes: + - postgres_data:/var/lib/postgresql/data + ports: + - "5433:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 5s + retries: 5 + restart: always + + app: + build: + context: . + dockerfile: ./Dockerfile + environment: + DATABASE_URL: postgres://postgres:postgres@postgres:5432/shop?sslmode=disable + PORT: "8080" + ports: + - "8080:8080" + depends_on: + postgres: + condition: service_healthy + restart: always + + prometheus: + build: + context: ./settings/prometheus + dockerfile: Dockerfile + volumes: + - prometheus_data:/prometheus + ports: + - "9090:9090" + restart: always + + grafana: + image: grafana/grafana:latest + ports: + - "3000:3000" + environment: + GF_SECURITY_ADMIN_USER: admin + GF_SECURITY_ADMIN_PASSWORD: admin + volumes: + - grafana_data:/var/lib/grafana + - ./settings/grafana/provisioning:/etc/grafana/provisioning:ro + restart: always + +volumes: + postgres_data: + prometheus_data: + grafana_data: + diff --git a/lecture4/go.mod b/lecture4/go.mod new file mode 100644 index 00000000..be32c00a --- /dev/null +++ b/lecture4/go.mod @@ -0,0 +1,59 @@ +module lecture4 + +go 1.24 + +require ( + github.com/gin-gonic/gin v1.10.0 + github.com/lib/pq v1.10.9 + github.com/prometheus/client_golang v1.20.5 + github.com/swaggo/files v1.0.1 + github.com/swaggo/gin-swagger v1.6.0 + github.com/swaggo/swag v1.16.4 +) + +require ( + github.com/KyleBanks/depth v1.2.1 // indirect + github.com/PuerkitoBio/purell v1.1.1 // indirect + github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/bytedance/sonic v1.11.6 // indirect + github.com/bytedance/sonic/loader v0.1.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/cloudwego/base64x v0.1.4 // indirect + github.com/cloudwego/iasm v0.2.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.3 // indirect + github.com/gin-contrib/sse v0.1.0 // indirect + github.com/go-openapi/jsonpointer v0.19.5 // indirect + github.com/go-openapi/jsonreference v0.19.6 // indirect + github.com/go-openapi/spec v0.20.4 // indirect + github.com/go-openapi/swag v0.19.15 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.20.0 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/compress v1.17.9 // indirect + github.com/klauspost/cpuid/v2 v2.2.7 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mailru/easyjson v0.7.6 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/prometheus/client_model v0.6.1 // indirect + github.com/prometheus/common v0.55.0 // indirect + github.com/prometheus/procfs v0.15.1 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.2.12 // indirect + golang.org/x/arch v0.8.0 // indirect + golang.org/x/crypto v0.24.0 // indirect + golang.org/x/net v0.26.0 // indirect + golang.org/x/sys v0.22.0 // indirect + golang.org/x/text v0.16.0 // indirect + golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect + google.golang.org/protobuf v1.34.2 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/lecture4/go.sum b/lecture4/go.sum new file mode 100644 index 00000000..faf66918 --- /dev/null +++ b/lecture4/go.sum @@ -0,0 +1,191 @@ +github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= +github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= +github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI= +github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= +github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M= +github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0= +github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= +github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= +github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= +github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= +github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= +github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= +github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= +github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4= +github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk= +github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= +github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY= +github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/jsonreference v0.19.6 h1:UBIxjkht+AWIgYzCDSv2GN+E/togfwXUJFRTWhl2Jjs= +github.com/go-openapi/jsonreference v0.19.6/go.mod h1:diGHMEHg2IqXZGKxqyvWdfWU/aim5Dprw5bqpKkTvns= +github.com/go-openapi/spec v0.20.4 h1:O8hJrt0UMnhHcluhIdUgCLRWyM2x7QkBXRvOs7m+O1M= +github.com/go-openapi/spec v0.20.4/go.mod h1:faYFR1CvsJZ0mNsmsphTMSoRrNV3TEDoAM7FOEWeq8I= +github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= +github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM= +github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8= +github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= +github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= +github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= +github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA= +github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= +github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y= +github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE= +github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= +github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= +github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc= +github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8= +github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= +github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE= +github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg= +github.com/swaggo/gin-swagger v1.6.0 h1:y8sxvQ3E20/RCyrXeFfg60r6H0Z+SwpTjMYsMm+zy8M= +github.com/swaggo/gin-swagger v1.6.0/go.mod h1:BG00cCEy294xtVpyIAHG6+e2Qzj/xKlRdOqDkvq0uzo= +github.com/swaggo/swag v1.16.4 h1:clWJtd9LStiG3VeijiCfOVODP6VpHtKdQy9ELFG3s1A= +github.com/swaggo/swag v1.16.4/go.mod h1:VBsHJRsDvfYvqoiMKnsdwhNV9LEMHgEDZcyVYX0sxPg= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= +github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= +golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI= +golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ= +golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= +golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= +golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= +google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/lecture4/graphana.jpg b/lecture4/graphana.jpg new file mode 100644 index 00000000..b23db405 Binary files /dev/null and b/lecture4/graphana.jpg differ diff --git a/lecture4/graphana_ddos.jpg b/lecture4/graphana_ddos.jpg new file mode 100644 index 00000000..6bfd8d77 Binary files /dev/null and b/lecture4/graphana_ddos.jpg differ diff --git a/lecture4/internal/api/handlers.go b/lecture4/internal/api/handlers.go new file mode 100644 index 00000000..c499e2a1 --- /dev/null +++ b/lecture4/internal/api/handlers.go @@ -0,0 +1,574 @@ +package api + +import ( + "context" + "database/sql" + "net/http" + "strconv" + + "lecture4/internal/repository" + + "github.com/gin-gonic/gin" + "github.com/lib/pq" +) + +type Handler struct { + queries *repository.Queries + db *sql.DB +} + +func NewHandler(db *sql.DB) *Handler { + return &Handler{ + queries: repository.New(db), + db: db, + } +} + +// Обобщенная функция для конверсии любого типа Row с полями ID, Name, Price, Deleted в ItemResponse +func rowToItemResponse(id int32, name string, price float64, deleted bool) ItemResponse { + return ItemResponse{ + ID: id, + Name: name, + Price: price, + Deleted: deleted, + } +} + +// cartItemsToResponse преобразует список GetCartItemsRow в CartItemResponse +func cartItemsToResponse(items []repository.GetCartItemsRow) []CartItemResponse { + result := make([]CartItemResponse, 0, len(items)) + for _, ci := range items { + result = append(result, CartItemResponse{ + ID: ci.ItemID, + Name: ci.Name, + Quantity: ci.Quantity, + Available: !ci.Deleted, + }) + } + return result +} + +// cartItemsForCartsToResponse преобразует список GetCartItemsForCartsRow в CartItemResponse +func cartItemsForCartsToResponse(items []repository.GetCartItemsForCartsRow) []CartItemResponse { + result := make([]CartItemResponse, 0, len(items)) + for _, ci := range items { + result = append(result, CartItemResponse{ + ID: ci.ItemID, + Name: ci.Name, + Quantity: ci.Quantity, + Available: !ci.Deleted, + }) + } + return result +} + +// buildCartResponse строит полный ответ с информацией о корзине +func (h *Handler) buildCartResponse(ctx context.Context, cartID int32) (CartResponse, error) { + cartItems, err := h.queries.GetCartItems(ctx, cartID) + if err != nil { + return CartResponse{}, err + } + + totalPrice, err := h.queries.GetCartTotalPrice(ctx, cartID) + if err != nil { + return CartResponse{}, err + } + + items := cartItemsToResponse(cartItems) + + return CartResponse{ + ID: cartID, + Items: items, + Price: totalPrice, + }, nil +} + +// PostItem godoc +// @Summary Создать товар +// @Description Создает новый товар +// @Tags items +// @Accept json +// @Produce json +// @Param item body ItemRequest true "Данные товара" +// @Success 201 {object} ItemResponse +// @Failure 400 {object} ErrorResponse +// @Router /item [post] +func (h *Handler) PostItem(c *gin.Context) { + var req ItemRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, ErrorResponse{Message: err.Error()}) + return + } + + item, err := h.queries.CreateItem(c.Request.Context(), repository.CreateItemParams{ + Name: req.Name, + Price: req.Price, + }) + if err != nil { + c.JSON(http.StatusInternalServerError, ErrorResponse{Message: err.Error()}) + return + } + + c.Header("Location", "/item/"+strconv.Itoa(int(item.ID))) + c.JSON(http.StatusCreated, rowToItemResponse(item.ID, item.Name, item.Price, item.Deleted)) +} + +// GetItemByID godoc +// @Summary Получить товар по ID +// @Description Получает товар по его ID +// @Tags items +// @Produce json +// @Param id path int true "ID товара" +// @Success 200 {object} ItemResponse +// @Failure 404 {object} ErrorResponse +// @Router /item/{id} [get] +func (h *Handler) GetItemByID(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, ErrorResponse{Message: "Invalid item ID"}) + return + } + + item, err := h.queries.GetItem(c.Request.Context(), int32(id)) + if err != nil { + if err == sql.ErrNoRows { + c.JSON(http.StatusNotFound, ErrorResponse{Message: "Item with id=" + c.Param("id") + " not found"}) + return + } + c.JSON(http.StatusInternalServerError, ErrorResponse{Message: err.Error()}) + return + } + + if item.Deleted { + c.JSON(http.StatusNotFound, ErrorResponse{Message: "Item with id=" + c.Param("id") + " not found"}) + return + } + + c.JSON(http.StatusOK, rowToItemResponse(item.ID, item.Name, item.Price, item.Deleted)) +} + +// GetItems godoc +// @Summary Получить список товаров +// @Description Получает список товаров с фильтрацией и пагинацией +// @Tags items +// @Produce json +// @Param offset query int false "Смещение" default(0) +// @Param limit query int false "Лимит" default(10) +// @Param min_price query number false "Минимальная цена" +// @Param max_price query number false "Максимальная цена" +// @Param show_deleted query bool false "Показывать удаленные" default(false) +// @Success 200 {array} ItemResponse +// @Router /item [get] +func (h *Handler) GetItems(c *gin.Context) { + offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0")) + limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10")) + showDeleted := c.DefaultQuery("show_deleted", "false") == "true" + + var minPrice, maxPrice sql.NullFloat64 + if minPriceStr := c.Query("min_price"); minPriceStr != "" { + if val, err := strconv.ParseFloat(minPriceStr, 64); err == nil { + minPrice = sql.NullFloat64{Float64: val, Valid: true} + } + } + if maxPriceStr := c.Query("max_price"); maxPriceStr != "" { + if val, err := strconv.ParseFloat(maxPriceStr, 64); err == nil { + maxPrice = sql.NullFloat64{Float64: val, Valid: true} + } + } + + items, err := h.queries.GetItems(c.Request.Context(), repository.GetItemsParams{ + Limit: int32(limit), + Offset: int32(offset), + ShowDeleted: showDeleted, + MinPrice: minPrice, + MaxPrice: maxPrice, + }) + if err != nil { + c.JSON(http.StatusInternalServerError, ErrorResponse{Message: err.Error()}) + return + } + + result := make([]ItemResponse, 0, len(items)) + for _, item := range items { + result = append(result, rowToItemResponse(item.ID, item.Name, item.Price, item.Deleted)) + } + + c.JSON(http.StatusOK, result) +} + +// PutItem godoc +// @Summary Обновить товар +// @Description Полностью обновляет существующий товар +// @Tags items +// @Accept json +// @Produce json +// @Param id path int true "ID товара" +// @Param item body ItemRequest true "Данные товара" +// @Success 200 {object} ItemResponse +// @Failure 304 {object} ErrorResponse +// @Failure 400 {object} ErrorResponse +// @Router /item/{id} [put] +func (h *Handler) PutItem(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, ErrorResponse{Message: "Invalid item ID"}) + return + } + + var req ItemRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, ErrorResponse{Message: err.Error()}) + return + } + + // Проверяем, существует ли товар + existingItem, err := h.queries.GetItem(c.Request.Context(), int32(id)) + if err != nil { + if err == sql.ErrNoRows { + c.JSON(http.StatusNotModified, ErrorResponse{Message: "Item with id=" + c.Param("id") + " not found"}) + return + } + c.JSON(http.StatusInternalServerError, ErrorResponse{Message: err.Error()}) + return + } + + if existingItem.Deleted { + c.JSON(http.StatusNotModified, ErrorResponse{Message: "Item with id=" + c.Param("id") + " not found"}) + return + } + + item, err := h.queries.UpdateItem(c.Request.Context(), repository.UpdateItemParams{ + ID: int32(id), + Name: req.Name, + Price: req.Price, + }) + if err != nil { + c.JSON(http.StatusInternalServerError, ErrorResponse{Message: err.Error()}) + return + } + + c.JSON(http.StatusOK, rowToItemResponse(item.ID, item.Name, item.Price, item.Deleted)) +} + +// PatchItem godoc +// @Summary Частично обновить товар +// @Description Частично обновляет существующий товар (кроме поля deleted) +// @Tags items +// @Accept json +// @Produce json +// @Param id path int true "ID товара" +// @Param item body PatchItemRequest true "Данные для обновления" +// @Success 200 {object} ItemResponse +// @Failure 304 {object} ErrorResponse +// @Failure 400 {object} ErrorResponse +// @Router /item/{id} [patch] +func (h *Handler) PatchItem(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, ErrorResponse{Message: "Invalid item ID"}) + return + } + + var req PatchItemRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, ErrorResponse{Message: err.Error()}) + return + } + + if req.Name != nil && req.Price != nil { + item, err := h.queries.PatchItemBoth(c.Request.Context(), repository.PatchItemBothParams{ + ID: int32(id), + Name: *req.Name, + Price: *req.Price, + }) + if err != nil { + if err == sql.ErrNoRows { + c.JSON(http.StatusNotModified, ErrorResponse{Message: "Item with id=" + c.Param("id") + " not found or is deleted"}) + return + } + c.JSON(http.StatusInternalServerError, ErrorResponse{Message: err.Error()}) + return + } + c.JSON(http.StatusOK, rowToItemResponse(item.ID, item.Name, item.Price, item.Deleted)) + } else if req.Name != nil { + item, err := h.queries.PatchItemName(c.Request.Context(), repository.PatchItemNameParams{ + ID: int32(id), + Name: *req.Name, + }) + if err != nil { + if err == sql.ErrNoRows { + c.JSON(http.StatusNotModified, ErrorResponse{Message: "Item with id=" + c.Param("id") + " not found or is deleted"}) + return + } + c.JSON(http.StatusInternalServerError, ErrorResponse{Message: err.Error()}) + return + } + c.JSON(http.StatusOK, rowToItemResponse(item.ID, item.Name, item.Price, item.Deleted)) + } else if req.Price != nil { + item, err := h.queries.PatchItemPrice(c.Request.Context(), repository.PatchItemPriceParams{ + ID: int32(id), + Price: *req.Price, + }) + if err != nil { + if err == sql.ErrNoRows { + c.JSON(http.StatusNotModified, ErrorResponse{Message: "Item with id=" + c.Param("id") + " not found or is deleted"}) + return + } + c.JSON(http.StatusInternalServerError, ErrorResponse{Message: err.Error()}) + return + } + c.JSON(http.StatusOK, rowToItemResponse(item.ID, item.Name, item.Price, item.Deleted)) + } else { + // Нет полей для обновления - возвращаем текущий товар + existingItem, err := h.queries.GetItem(c.Request.Context(), int32(id)) + if err != nil { + if err == sql.ErrNoRows { + c.JSON(http.StatusNotModified, ErrorResponse{Message: "Item with id=" + c.Param("id") + " not found or is deleted"}) + return + } + c.JSON(http.StatusInternalServerError, ErrorResponse{Message: err.Error()}) + return + } + if existingItem.Deleted { + c.JSON(http.StatusNotModified, ErrorResponse{Message: "Item with id=" + c.Param("id") + " not found or is deleted"}) + return + } + c.JSON(http.StatusOK, rowToItemResponse(existingItem.ID, existingItem.Name, existingItem.Price, existingItem.Deleted)) + } +} + +// DeleteItem godoc +// @Summary Удалить товар +// @Description Помечает товар как удаленный +// @Tags items +// @Param id path int true "ID товара" +// @Success 200 +// @Router /item/{id} [delete] +func (h *Handler) DeleteItem(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, ErrorResponse{Message: "Invalid item ID"}) + return + } + + err = h.queries.DeleteItem(c.Request.Context(), int32(id)) + if err != nil { + c.JSON(http.StatusInternalServerError, ErrorResponse{Message: err.Error()}) + return + } + + c.Status(http.StatusOK) +} + +// PostCart godoc +// @Summary Создать корзину +// @Description Создает новую пустую корзину +// @Tags carts +// @Produce json +// @Success 201 {object} CartIdResponse +// @Router /cart [post] +func (h *Handler) PostCart(c *gin.Context) { + cart, err := h.queries.CreateCart(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, ErrorResponse{Message: err.Error()}) + return + } + + c.Header("Location", "/cart/"+strconv.Itoa(int(cart.ID))) + c.JSON(http.StatusCreated, CartIdResponse{ID: cart.ID}) +} + +// GetCartByID godoc +// @Summary Получить корзину по ID +// @Description Получает корзину по её ID с расчетом общей стоимости +// @Tags carts +// @Produce json +// @Param id path int true "ID корзины" +// @Success 200 {object} CartResponse +// @Failure 404 {object} ErrorResponse +// @Router /cart/{id} [get] +func (h *Handler) GetCartByID(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, ErrorResponse{Message: "Invalid cart ID"}) + return + } + + cart, err := h.queries.GetCart(c.Request.Context(), int32(id)) + if err != nil { + if err == sql.ErrNoRows { + c.JSON(http.StatusNotFound, ErrorResponse{Message: "Cart with id=" + c.Param("id") + " not found"}) + return + } + c.JSON(http.StatusInternalServerError, ErrorResponse{Message: err.Error()}) + return + } + + cartResponse, err := h.buildCartResponse(c.Request.Context(), cart.ID) + if err != nil { + c.JSON(http.StatusInternalServerError, ErrorResponse{Message: err.Error()}) + return + } + + c.JSON(http.StatusOK, cartResponse) +} + +// GetCarts godoc +// @Summary Получить список корзин +// @Description Получает список корзин с фильтрацией и пагинацией +// @Tags carts +// @Produce json +// @Param offset query int false "Смещение" default(0) +// @Param limit query int false "Лимит" default(10) +// @Param min_price query number false "Минимальная цена" +// @Param max_price query number false "Максимальная цена" +// @Param min_quantity query int false "Минимальное количество товаров" +// @Param max_quantity query int false "Максимальное количество товаров" +// @Success 200 {array} CartResponse +// @Router /cart [get] +func (h *Handler) GetCarts(c *gin.Context) { + offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0")) + limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10")) + + var minPrice, maxPrice sql.NullFloat64 + var minQuantity, maxQuantity sql.NullInt32 + + if minPriceStr := c.Query("min_price"); minPriceStr != "" { + if val, err := strconv.ParseFloat(minPriceStr, 64); err == nil { + minPrice = sql.NullFloat64{Float64: val, Valid: true} + } + } + if maxPriceStr := c.Query("max_price"); maxPriceStr != "" { + if val, err := strconv.ParseFloat(maxPriceStr, 64); err == nil { + maxPrice = sql.NullFloat64{Float64: val, Valid: true} + } + } + if minQtyStr := c.Query("min_quantity"); minQtyStr != "" { + if val, err := strconv.Atoi(minQtyStr); err == nil { + minQuantity = sql.NullInt32{Int32: int32(val), Valid: true} + } + } + if maxQtyStr := c.Query("max_quantity"); maxQtyStr != "" { + if val, err := strconv.Atoi(maxQtyStr); err == nil { + maxQuantity = sql.NullInt32{Int32: int32(val), Valid: true} + } + } + + carts, err := h.queries.GetAllCartsWithStats(c.Request.Context(), repository.GetAllCartsWithStatsParams{ + Limit: int32(limit), + Offset: int32(offset), + MinPrice: minPrice, + MaxPrice: maxPrice, + MinQuantity: minQuantity, + MaxQuantity: maxQuantity, + }) + if err != nil { + c.JSON(http.StatusInternalServerError, ErrorResponse{Message: err.Error()}) + return + } + + cartIDs := make([]int32, len(carts)) + for i, cart := range carts { + cartIDs[i] = cart.ID + } + + var allCartItems []repository.GetCartItemsForCartsRow + if len(cartIDs) > 0 { + allCartItems, err = h.queries.GetCartItemsForCarts(c.Request.Context(), cartIDs) + if err != nil { + c.JSON(http.StatusInternalServerError, ErrorResponse{Message: err.Error()}) + return + } + } + + // Группируем товары по корзинам + cartItemsMap := make(map[int32][]repository.GetCartItemsForCartsRow) + for _, ci := range allCartItems { + cartItemsMap[ci.CartID] = append(cartItemsMap[ci.CartID], ci) + } + + result := make([]CartResponse, 0, len(carts)) + for _, cart := range carts { + items := cartItemsForCartsToResponse(cartItemsMap[cart.ID]) + + result = append(result, CartResponse{ + ID: cart.ID, + Items: items, + Price: cart.TotalPrice, + }) + } + + c.JSON(http.StatusOK, result) +} + +// AddItemToCart godoc +// @Summary Добавить товар в корзину +// @Description Добавляет товар в корзину или увеличивает его количество +// @Tags carts +// @Produce json +// @Param cart_id path int true "ID корзины" +// @Param item_id path int true "ID товара" +// @Success 200 {object} CartResponse +// @Failure 404 {object} ErrorResponse +// @Router /cart/{cart_id}/add/{item_id} [post] +func (h *Handler) AddItemToCart(c *gin.Context) { + cartID, err := strconv.Atoi(c.Param("cart_id")) + if err != nil { + c.JSON(http.StatusBadRequest, ErrorResponse{Message: "Invalid cart ID"}) + return + } + + itemID, err := strconv.Atoi(c.Param("item_id")) + if err != nil { + c.JSON(http.StatusBadRequest, ErrorResponse{Message: "Invalid item ID"}) + return + } + + // Проверяем существование корзины + _, err = h.queries.GetCart(c.Request.Context(), int32(cartID)) + if err != nil { + if err == sql.ErrNoRows { + c.JSON(http.StatusNotFound, ErrorResponse{Message: "Cart with id=" + c.Param("cart_id") + " or item with id=" + c.Param("item_id") + " not found"}) + return + } + c.JSON(http.StatusInternalServerError, ErrorResponse{Message: err.Error()}) + return + } + + // Проверяем существование товара + _, err = h.queries.GetItem(c.Request.Context(), int32(itemID)) + if err != nil { + if err == sql.ErrNoRows { + c.JSON(http.StatusNotFound, ErrorResponse{Message: "Cart with id=" + c.Param("cart_id") + " or item with id=" + c.Param("item_id") + " not found"}) + return + } + c.JSON(http.StatusInternalServerError, ErrorResponse{Message: err.Error()}) + return + } + + // Добавляем товар в корзину + _, err = h.queries.AddItemToCart(c.Request.Context(), repository.AddItemToCartParams{ + CartID: int32(cartID), + ItemID: int32(itemID), + }) + if err != nil { + // Проверяем ошибку foreign key constraint + if pqErr, ok := err.(*pq.Error); ok { + if pqErr.Code == "23503" { // foreign_key_violation + c.JSON(http.StatusNotFound, ErrorResponse{Message: "Cart with id=" + c.Param("cart_id") + " or item with id=" + c.Param("item_id") + " not found"}) + return + } + } + c.JSON(http.StatusInternalServerError, ErrorResponse{Message: err.Error()}) + return + } + + // Возвращаем обновленную корзину + cartResponse, err := h.buildCartResponse(c.Request.Context(), int32(cartID)) + if err != nil { + c.JSON(http.StatusInternalServerError, ErrorResponse{Message: err.Error()}) + return + } + + c.JSON(http.StatusOK, cartResponse) +} diff --git a/lecture4/internal/api/models.go b/lecture4/internal/api/models.go new file mode 100644 index 00000000..39f3c60a --- /dev/null +++ b/lecture4/internal/api/models.go @@ -0,0 +1,46 @@ +package api + +// ItemRequest представляет запрос на создание или обновление товара +type ItemRequest struct { + Name string `json:"name" binding:"required" example:"Молоко"` + Price float64 `json:"price" binding:"required,gte=0" example:"159.99"` +} + +// PatchItemRequest представляет запрос на частичное обновление товара +type PatchItemRequest struct { + Name *string `json:"name,omitempty" example:"Молоко обновленное"` + Price *float64 `json:"price,omitempty" example:"169.99"` +} + +// ItemResponse представляет ответ с информацией о товаре +type ItemResponse struct { + ID int32 `json:"id" example:"1"` + Name string `json:"name" example:"Молоко"` + Price float64 `json:"price" example:"159.99"` + Deleted bool `json:"deleted" example:"false"` +} + +// CartItemResponse представляет товар в корзине +type CartItemResponse struct { + ID int32 `json:"id" example:"1"` + Name string `json:"name" example:"Молоко"` + Quantity int32 `json:"quantity" example:"3"` + Available bool `json:"available" example:"true"` +} + +// CartResponse представляет ответ с информацией о корзине +type CartResponse struct { + ID int32 `json:"id" example:"1"` + Items []CartItemResponse `json:"items"` + Price float64 `json:"price" example:"234.40"` +} + +// CartIdResponse представляет ответ с ID корзины +type CartIdResponse struct { + ID int32 `json:"id" example:"1"` +} + +// ErrorResponse представляет ответ с ошибкой +type ErrorResponse struct { + Message string `json:"detail" example:"Item not found"` +} diff --git a/lecture4/internal/config/config.go b/lecture4/internal/config/config.go new file mode 100644 index 00000000..a5e212b7 --- /dev/null +++ b/lecture4/internal/config/config.go @@ -0,0 +1,27 @@ +package config + +import ( + "os" +) + +type Config struct { + DatabaseURL string + Port string +} + +func Load() *Config { + databaseURL := os.Getenv("DATABASE_URL") + if databaseURL == "" { + databaseURL = "postgres://postgres:postgres@localhost:5432/shop?sslmode=disable" + } + + port := os.Getenv("PORT") + if port == "" { + port = "8080" + } + + return &Config{ + DatabaseURL: databaseURL, + Port: port, + } +} diff --git a/lecture4/internal/repository/queries.sql b/lecture4/internal/repository/queries.sql new file mode 100644 index 00000000..e17071dd --- /dev/null +++ b/lecture4/internal/repository/queries.sql @@ -0,0 +1,111 @@ +-- Items queries + +-- name: CreateItem :one +INSERT INTO items (name, price, deleted) +VALUES (sqlc.arg('name'), sqlc.arg('price')::float8, FALSE) +RETURNING id, name, price::float8 AS price, deleted, created_at; + +-- name: GetItem :one +SELECT id, name, price::float8 AS price, deleted, created_at FROM items WHERE id = $1; + +-- name: GetItems :many +SELECT id, name, price::float8 AS price, deleted, created_at FROM items +WHERE + (sqlc.arg(show_deleted)::boolean = TRUE OR deleted = FALSE) + AND (sqlc.narg('min_price')::float8 IS NULL OR price >= sqlc.narg('min_price')) + AND (sqlc.narg('max_price')::float8 IS NULL OR price <= sqlc.narg('max_price')) +ORDER BY id +LIMIT $1 OFFSET $2; + +-- name: UpdateItem :one +UPDATE items +SET name = sqlc.arg('name'), price = sqlc.arg('price')::float8 +WHERE id = sqlc.arg('id') +RETURNING id, name, price::float8 AS price, deleted, created_at; + +-- name: PatchItemName :one +UPDATE items +SET name = sqlc.arg('name') +WHERE id = sqlc.arg('id') AND deleted = FALSE +RETURNING id, name, price::float8 AS price, deleted, created_at; + +-- name: PatchItemPrice :one +UPDATE items +SET price = sqlc.arg('price')::float8 +WHERE id = sqlc.arg('id') AND deleted = FALSE +RETURNING id, name, price::float8 AS price, deleted, created_at; + +-- name: PatchItemBoth :one +UPDATE items +SET name = sqlc.arg('name'), price = sqlc.arg('price')::float8 +WHERE id = sqlc.arg('id') AND deleted = FALSE +RETURNING id, name, price::float8 AS price, deleted, created_at; + +-- name: DeleteItem :exec +UPDATE items +SET deleted = TRUE +WHERE id = $1; + +-- Carts queries + +-- name: CreateCart :one +INSERT INTO carts DEFAULT VALUES +RETURNING *; + +-- name: GetCart :one +SELECT * FROM carts WHERE id = $1; + +-- name: GetCarts :many +SELECT * FROM carts +ORDER BY id +LIMIT $1 OFFSET $2; + +-- name: GetCartItems :many +SELECT ci.cart_id, ci.item_id, ci.quantity, i.name, i.price::float8 AS price, i.deleted +FROM cart_items ci +JOIN items i ON ci.item_id = i.id +WHERE ci.cart_id = $1 +ORDER BY ci.item_id; + +-- name: GetCartItemsForCarts :many +SELECT ci.cart_id, ci.item_id, ci.quantity, i.name, i.price::float8 AS price, i.deleted +FROM cart_items ci +JOIN items i ON ci.item_id = i.id +WHERE ci.cart_id = ANY($1::int[]) +ORDER BY ci.cart_id, ci.item_id; + +-- name: AddItemToCart :one +INSERT INTO cart_items (cart_id, item_id, quantity) +VALUES ($1, $2, 1) +ON CONFLICT (cart_id, item_id) +DO UPDATE SET quantity = cart_items.quantity + 1 +RETURNING *; + +-- name: GetCartTotalPrice :one +SELECT COALESCE(SUM(ci.quantity * i.price), 0)::float8 as total +FROM cart_items ci +JOIN items i ON ci.item_id = i.id +WHERE ci.cart_id = $1 AND i.deleted = FALSE; + +-- name: GetCartTotalQuantity :one +SELECT COALESCE(SUM(ci.quantity), 0)::int8 as total +FROM cart_items ci +WHERE ci.cart_id = $1; + +-- name: GetAllCartsWithStats :many +SELECT + c.id, + COALESCE(SUM(ci.quantity), 0)::int8 as total_quantity, + COALESCE(SUM(CASE WHEN i.deleted = FALSE THEN ci.quantity * i.price ELSE 0 END), 0)::float8 as total_price +FROM carts c +LEFT JOIN cart_items ci ON c.id = ci.cart_id +LEFT JOIN items i ON ci.item_id = i.id +GROUP BY c.id +HAVING + (sqlc.narg('min_price')::float8 IS NULL OR COALESCE(SUM(CASE WHEN i.deleted = FALSE THEN ci.quantity * i.price ELSE 0 END), 0) >= sqlc.narg('min_price')) + AND (sqlc.narg('max_price')::float8 IS NULL OR COALESCE(SUM(CASE WHEN i.deleted = FALSE THEN ci.quantity * i.price ELSE 0 END), 0) <= sqlc.narg('max_price')) + AND (sqlc.narg('min_quantity')::int IS NULL OR COALESCE(SUM(ci.quantity), 0) >= sqlc.narg('min_quantity')) + AND (sqlc.narg('max_quantity')::int IS NULL OR COALESCE(SUM(ci.quantity), 0) <= sqlc.narg('max_quantity')) +ORDER BY c.id +LIMIT $1 OFFSET $2; + diff --git a/lecture4/migrations/Dockerfile b/lecture4/migrations/Dockerfile new file mode 100644 index 00000000..a0623feb --- /dev/null +++ b/lecture4/migrations/Dockerfile @@ -0,0 +1,6 @@ +FROM postgres:15-alpine + +COPY shop_init.sql /docker-entrypoint-initdb.d/init.sql + +EXPOSE 5432 + diff --git a/lecture4/migrations/shop_init.sql b/lecture4/migrations/shop_init.sql new file mode 100644 index 00000000..77be4e62 --- /dev/null +++ b/lecture4/migrations/shop_init.sql @@ -0,0 +1,42 @@ +-- Создание схемы базы данных для Shop API +DROP TABLE IF EXISTS cart_items CASCADE; +DROP TABLE IF EXISTS carts CASCADE; +DROP TABLE IF EXISTS items CASCADE; + +-- Таблица товаров +CREATE TABLE items ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + price NUMERIC(10, 2) NOT NULL CHECK (price >= 0), + deleted BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Таблица корзин +CREATE TABLE carts ( + id SERIAL PRIMARY KEY, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Таблица связи корзин и товаров +CREATE TABLE cart_items ( + cart_id INTEGER NOT NULL REFERENCES carts(id) ON DELETE CASCADE, + item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE, + quantity INTEGER NOT NULL CHECK (quantity > 0), + PRIMARY KEY (cart_id, item_id) +); + +-- Индексы для оптимизации запросов +CREATE INDEX idx_items_deleted ON items(deleted); +CREATE INDEX idx_items_price ON items(price); +CREATE INDEX idx_cart_items_cart_id ON cart_items(cart_id); +CREATE INDEX idx_cart_items_item_id ON cart_items(item_id); + +-- Вставка тестовых данных для проверки +INSERT INTO items (name, price, deleted) VALUES + ('Туалетная бумага "Поцелуй", рулон', 50.00, FALSE), + ('Золотая цепочка "Abendsonne"', 15000.00, FALSE), + ('Молоко "Буреночка" 1л.', 159.99, FALSE), + ('Хлеб белый', 45.50, FALSE), + ('Удаленный товар', 100.00, TRUE); + diff --git a/lecture4/requirements.txt b/lecture4/requirements.txt new file mode 100644 index 00000000..4a296010 --- /dev/null +++ b/lecture4/requirements.txt @@ -0,0 +1,2 @@ +requests==2.31.0 +faker==20.1.0 diff --git a/lecture4/settings/grafana/provisioning/dashboards/shop-api.json b/lecture4/settings/grafana/provisioning/dashboards/shop-api.json new file mode 100644 index 00000000..a7b09c5a --- /dev/null +++ b/lecture4/settings/grafana/provisioning/dashboards/shop-api.json @@ -0,0 +1,419 @@ +{ + "annotations": { + "list": [] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": null, + "links": [], + "liveNow": false, + "panels": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "rate(promhttp_metric_handler_requests_total[1m])", + "refId": "A" + } + ], + "title": "HTTP Requests Rate", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 50 + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "percent" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 0 + }, + "id": 2, + "options": { + "orientation": "auto", + "reduceOptions": { + "values": false, + "calcs": [ + "lastNotNull" + ], + "fields": "" + }, + "showThresholdLabels": false, + "showThresholdMarkers": true + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "rate(process_cpu_seconds_total[1m]) * 100", + "refId": "A" + } + ], + "title": "CPU Usage", + "type": "gauge" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "bytes" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 8 + }, + "id": 3, + "options": { + "legend": { + "calcs": [ + "mean", + "lastNotNull", + "max" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "process_resident_memory_bytes", + "refId": "A" + } + ], + "title": "Memory Usage", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 8 + }, + "id": 4, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "go_goroutines", + "refId": "A" + } + ], + "title": "Goroutines", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 24, + "x": 0, + "y": 16 + }, + "id": 5, + "options": { + "displayMode": "gradient", + "minVizHeight": 10, + "minVizWidth": 0, + "orientation": "horizontal", + "reduceOptions": { + "values": false, + "calcs": [ + "lastNotNull" + ], + "fields": "" + }, + "showUnfilled": true + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "sum by (code) (promhttp_metric_handler_requests_total)", + "refId": "A" + } + ], + "title": "HTTP Status Codes", + "type": "bargauge" + } + ], + "refresh": "5s", + "schemaVersion": 38, + "style": "dark", + "tags": [ + "shop-api", + "golang" + ], + "templating": { + "list": [] + }, + "time": { + "from": "now-15m", + "to": "now" + }, + "timepicker": {}, + "timezone": "", + "title": "Shop API Monitoring", + "uid": "shop-api-dashboard", + "version": 1, + "weekStart": "" +} diff --git a/lecture4/settings/prometheus/Dockerfile b/lecture4/settings/prometheus/Dockerfile new file mode 100644 index 00000000..a1e0c986 --- /dev/null +++ b/lecture4/settings/prometheus/Dockerfile @@ -0,0 +1,11 @@ +FROM prom/prometheus:latest + +COPY prometheus.yml /etc/prometheus/prometheus.yml + +EXPOSE 9090 + +CMD ["--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"] + diff --git a/lecture4/settings/prometheus/prometheus.yml b/lecture4/settings/prometheus/prometheus.yml new file mode 100644 index 00000000..bcde8d83 --- /dev/null +++ b/lecture4/settings/prometheus/prometheus.yml @@ -0,0 +1,11 @@ +global: + scrape_interval: 10s + evaluation_interval: 10s + +scrape_configs: + - job_name: shop-api + metrics_path: /metrics + static_configs: + - targets: + - app:8080 + diff --git a/lecture4/sqlc.yaml b/lecture4/sqlc.yaml new file mode 100644 index 00000000..ad5f458a --- /dev/null +++ b/lecture4/sqlc.yaml @@ -0,0 +1,12 @@ +version: "2" +sql: + - engine: "postgresql" + queries: "internal/repository/queries.sql" + schema: "migrations/shop_init.sql" + gen: + go: + package: "repository" + out: "internal/repository" + emit_json_tags: true + emit_interface: false + emit_exact_table_names: false