From 1fbbea89bd28208e2895b262b77c7ad6ec00921c Mon Sep 17 00:00:00 2001 From: Dima Kushchevskyi Date: Sun, 18 Jan 2026 16:12:12 +0200 Subject: [PATCH 1/4] feat(api): add servers API with postgres migrations and docker setup - implement async servers CRUD API - add postgres migrations and migrator - add docker and compose infrastructure - verify smoke test passes --- API.md | 4 + Makefile | 32 +++ infra/compose/.env | 6 + infra/compose/common/db.yml | 21 ++ infra/compose/dev.yml | 34 +++ infra/compose/stage.yml | 33 +++ projects/api/.gitignore | 1 + projects/api/.python-version | 1 + projects/api/Dockerfile | 71 ++++++ projects/api/main.py | 15 ++ projects/api/pyproject.toml | 12 + .../src/__pycache__/settings.cpython-312.pyc | Bin 0 -> 1002 bytes projects/api/src/app.py | 32 +++ projects/api/src/db/db.py | 18 ++ projects/api/src/db/postgres.py | 18 ++ projects/api/src/deps.py | 27 ++ projects/api/src/domain/exceptions.py | 5 + projects/api/src/domain/servers.py | 31 +++ projects/api/src/exceptions.py | 37 +++ .../src/repositories/interfaces/servers.py | 29 +++ .../postgres/servers/queries/create.sql | 3 + .../postgres/servers/queries/delete.sql | 3 + .../servers/queries/exists_by_hostname.sql | 3 + .../postgres/servers/queries/get_by_id.sql | 3 + .../postgres/servers/queries/list.sql | 2 + .../postgres/servers/queries/update.sql | 6 + .../repositories/postgres/servers/servers.py | 71 ++++++ projects/api/src/routes/__init__.py | 8 + projects/api/src/routes/servers/router.py | 62 +++++ .../src/routes/servers/schemas/__init__.py | 8 + .../src/routes/servers/schemas/requests.py | 19 ++ .../src/routes/servers/schemas/response.py | 11 + projects/api/src/services/exceptions.py | 10 + projects/api/src/services/servers_service.py | 37 +++ projects/api/src/settings.py | 17 ++ projects/api/uv.lock | 237 ++++++++++++++++++ projects/cli/Dockerfile | 0 projects/cli/__main__.py | 0 projects/cli/client.py | 0 projects/cli/commands/servers.py | 0 projects/migrator/postgres/.gitignore | 1 + projects/migrator/postgres/.python-version | 1 + projects/migrator/postgres/Dockerfile | 68 +++++ projects/migrator/postgres/main.py | 76 ++++++ projects/migrator/postgres/pyproject.toml | 9 + projects/migrator/postgres/uv.lock | 23 ++ .../postgres/versions/001_create_servers.sql | 6 + 47 files changed, 1111 insertions(+) create mode 100644 API.md create mode 100644 Makefile create mode 100644 infra/compose/.env create mode 100644 infra/compose/common/db.yml create mode 100644 infra/compose/dev.yml create mode 100644 infra/compose/stage.yml create mode 100644 projects/api/.gitignore create mode 100644 projects/api/.python-version create mode 100644 projects/api/Dockerfile create mode 100644 projects/api/main.py create mode 100644 projects/api/pyproject.toml create mode 100644 projects/api/src/__pycache__/settings.cpython-312.pyc create mode 100644 projects/api/src/app.py create mode 100644 projects/api/src/db/db.py create mode 100644 projects/api/src/db/postgres.py create mode 100644 projects/api/src/deps.py create mode 100644 projects/api/src/domain/exceptions.py create mode 100644 projects/api/src/domain/servers.py create mode 100644 projects/api/src/exceptions.py create mode 100644 projects/api/src/repositories/interfaces/servers.py create mode 100644 projects/api/src/repositories/postgres/servers/queries/create.sql create mode 100644 projects/api/src/repositories/postgres/servers/queries/delete.sql create mode 100644 projects/api/src/repositories/postgres/servers/queries/exists_by_hostname.sql create mode 100644 projects/api/src/repositories/postgres/servers/queries/get_by_id.sql create mode 100644 projects/api/src/repositories/postgres/servers/queries/list.sql create mode 100644 projects/api/src/repositories/postgres/servers/queries/update.sql create mode 100644 projects/api/src/repositories/postgres/servers/servers.py create mode 100644 projects/api/src/routes/__init__.py create mode 100644 projects/api/src/routes/servers/router.py create mode 100644 projects/api/src/routes/servers/schemas/__init__.py create mode 100644 projects/api/src/routes/servers/schemas/requests.py create mode 100644 projects/api/src/routes/servers/schemas/response.py create mode 100644 projects/api/src/services/exceptions.py create mode 100644 projects/api/src/services/servers_service.py create mode 100644 projects/api/src/settings.py create mode 100644 projects/api/uv.lock create mode 100644 projects/cli/Dockerfile create mode 100644 projects/cli/__main__.py create mode 100644 projects/cli/client.py create mode 100644 projects/cli/commands/servers.py create mode 100644 projects/migrator/postgres/.gitignore create mode 100644 projects/migrator/postgres/.python-version create mode 100644 projects/migrator/postgres/Dockerfile create mode 100644 projects/migrator/postgres/main.py create mode 100644 projects/migrator/postgres/pyproject.toml create mode 100644 projects/migrator/postgres/uv.lock create mode 100644 projects/migrator/postgres/versions/001_create_servers.sql diff --git a/API.md b/API.md new file mode 100644 index 0000000..3928d68 --- /dev/null +++ b/API.md @@ -0,0 +1,4 @@ +# Swagger + +`http://localhost:8000/docs` + diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..2968cef --- /dev/null +++ b/Makefile @@ -0,0 +1,32 @@ +.PHONY: run_dev_services enter_dev_api enter_dev_mirations stop_dev_services run_stage clear_stage help + +.DEFAULT_GOAL := help + +## Show this help +help: + @awk 'BEGIN {FS = ":.*##"; printf "\nAvailable targets:\n\n"} \ + /^[a-zA-Z_-]+:.*##/ { printf " %-25s %s\n", $$1, $$2 }' $(MAKEFILE_LIST) + +## Run dev services (API, DB, migrator) in background +run_dev_services: clear_stage ## Start DEV environment (detached) + docker compose -f infra/compose/dev.yml up --build --detach + +## Enter dev API container shell +enter_dev_api: ## Enter DEV API container + docker compose -f infra/compose/dev.yml exec api.mathpix.com bash + +## Enter dev migrator container shell +enter_dev_mirations: ## Enter DEV migrator container + docker compose -f infra/compose/dev.yml exec migrator.mathpix.com bash + +## Stop and remove dev services and volumes +stop_dev_services: ## Stop DEV environment and remove volumes + docker compose -f infra/compose/dev.yml down --volumes + +## Run stage services (foreground) +run_stage: stop_dev_services ## Start STAGE environment + docker compose -f infra/compose/stage.yml up --build + +## Stop stage services and remove volumes +clear_stage: ## Stop STAGE environment and remove volumes + docker compose -f infra/compose/stage.yml down --volumes diff --git a/infra/compose/.env b/infra/compose/.env new file mode 100644 index 0000000..7aeb656 --- /dev/null +++ b/infra/compose/.env @@ -0,0 +1,6 @@ +POSTGRES_USER=postgres +POSTGRES_PASSWORD=QVlTe2IKrRY2unSDu8P5N436wpx59ur7ctSHB5doZwxi +POSTGRES_DB=inventory +POSTGRES_HOST=inventorydb.mathpix.com + +DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:5432/${POSTGRES_DB} diff --git a/infra/compose/common/db.yml b/infra/compose/common/db.yml new file mode 100644 index 0000000..81747fa --- /dev/null +++ b/infra/compose/common/db.yml @@ -0,0 +1,21 @@ +services: + inventorydb.mathpix.com: + image: postgres:18.1-alpine + container_name: inventorydb + restart: always + environment: + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: ${POSTGRES_DB} + volumes: + - db_data:/var/lib/postgresql/data + + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] + interval: 5s + timeout: 5s + retries: 5 + start_period: 5s + +volumes: + db_data: diff --git a/infra/compose/dev.yml b/infra/compose/dev.yml new file mode 100644 index 0000000..e73c3a2 --- /dev/null +++ b/infra/compose/dev.yml @@ -0,0 +1,34 @@ +include: + - common/db.yml +services: + migrator.mathpix.com: + build: + context: ../../projects/migrator/postgres + dockerfile: Dockerfile + target: dev + volumes: + - ../../projects/migrator/postgres:/app + container_name: migrator + depends_on: + - inventorydb.mathpix.com + environment: + DATABASE_URL: ${DATABASE_URL} + restart: no + + api.mathpix.com: + build: + context: ../../projects/api + dockerfile: Dockerfile + target: dev + volumes: + - ../../projects/api:/app + container_name: api + depends_on: + - inventorydb.mathpix.com + - migrator.mathpix.com + ports: + - 8000:8000 + environment: + DATABASE_URL: ${DATABASE_URL} + ENV: dev + restart: always diff --git a/infra/compose/stage.yml b/infra/compose/stage.yml new file mode 100644 index 0000000..40cd077 --- /dev/null +++ b/infra/compose/stage.yml @@ -0,0 +1,33 @@ +include: + - common/db.yml +services: + migrator.mathpix.com: + build: + context: ../../projects/migrator/postgres + dockerfile: Dockerfile + target: runtime + container_name: migrator + depends_on: + inventorydb.mathpix.com: + condition: service_healthy + environment: + DATABASE_URL: ${DATABASE_URL} + restart: no + + api.mathpix.com: + build: + context: ../../projects/api + dockerfile: Dockerfile + target: runtime + container_name: api + depends_on: + inventorydb.mathpix.com: + condition: service_healthy + migrator.mathpix.com: + condition: service_completed_successfully + ports: + - 8000:8000 + environment: + DATABASE_URL: ${DATABASE_URL} + ENV: stage + restart: always diff --git a/projects/api/.gitignore b/projects/api/.gitignore new file mode 100644 index 0000000..1d17dae --- /dev/null +++ b/projects/api/.gitignore @@ -0,0 +1 @@ +.venv diff --git a/projects/api/.python-version b/projects/api/.python-version new file mode 100644 index 0000000..e4fba21 --- /dev/null +++ b/projects/api/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/projects/api/Dockerfile b/projects/api/Dockerfile new file mode 100644 index 0000000..f1e14b7 --- /dev/null +++ b/projects/api/Dockerfile @@ -0,0 +1,71 @@ +################################################## +FROM python:3.12-slim AS base_builder + +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +WORKDIR /app + +RUN <!Ce}7cCz2AcFK#z3gRNGm~^-vm0k7g@^~i z3W^tR^Zeq#6e)GNg@!t2|y!}2q8z2}D>S?lv5&B_` z$#BPH^c=_m;)vrEb#a8T^4 z!W>1;3k1RN3akmroh>`Nz(~Oer{f*>|BhEM56)C9D-!S_f$E_HCJYc7gfqsUs0fK6 zT9`{d?a7=QBC2Ms8ffJI;xk>nq$9dLHI&bI(gxn6P@fpkFu3*drjCFI~Pc5%8_PQUZ;~gK) zvUZY+Z)jx0TsAhj=;oVBwl>wcx*NTL@k-}%0nS{5PgMZ+(Qt9)lP_-omra1}(3gwW zxNf0g!9KN_V=T_HT*o@eGX)}u($bJ5)5SHpY)=+m>?=s?y8zo~>Mp|O|2s|o0Lbcf(Kg{Wq+7==;zGh=le&rQ3!yF7*7v+(4}Q5 zs_lN((s`aLX&cqd_Xa%9bke%=XR|KDu39!7`_kVkpYox+1-n+Zx(9GhFvh2-d5UhH lqB|Efz|D_uFAz-UP2|j+I~Xs0*@epBr4fOJ-*%yp_zzkK=Ia0e literal 0 HcmV?d00001 diff --git a/projects/api/src/app.py b/projects/api/src/app.py new file mode 100644 index 0000000..9c29464 --- /dev/null +++ b/projects/api/src/app.py @@ -0,0 +1,32 @@ +from contextlib import asynccontextmanager +from fastapi import FastAPI + +from src.db.db import init_db +from src.routes import main_router +from src.settings import get_settings +from src.exceptions import register_exception_handlers + +@asynccontextmanager +async def lifespan(app: FastAPI): + settings = get_settings() + await init_db(settings.repository, settings.database_url) + yield + + +def create_app(main_router): + settings = get_settings() + + app = FastAPI( + title=f"Inventory Managment {settings.env.capitalize()} API", + lifespan=lifespan, + ) + + app.include_router(main_router) + register_exception_handlers(app) + + return app + + +app = create_app(main_router=main_router) + +__all__ = ["app"] diff --git a/projects/api/src/db/db.py b/projects/api/src/db/db.py new file mode 100644 index 0000000..926ff60 --- /dev/null +++ b/projects/api/src/db/db.py @@ -0,0 +1,18 @@ +from typing import Callable + +import src.db.postgres + +REPOSITORY_REGISTRY: dict[str, Callable[[str], None]] = { + "postgres": src.db.postgres.init_pool, +} + +async def init_db(repository: str, database_url: str) -> None: + try: + init_fn = REPOSITORY_REGISTRY[repository] + except KeyError: + raise RuntimeError( + f"Unsupported repository backend: {repository}. " + f"Supported: {list(REPOSITORY_REGISTRY.keys())}" + ) + + await init_fn(database_url) diff --git a/projects/api/src/db/postgres.py b/projects/api/src/db/postgres.py new file mode 100644 index 0000000..6254050 --- /dev/null +++ b/projects/api/src/db/postgres.py @@ -0,0 +1,18 @@ +import asyncpg + +_pool: asyncpg.Pool | None = None + + +async def init_pool(database_url: str) -> None: + global _pool + _pool = await asyncpg.create_pool( + dsn=database_url, + min_size=1, + max_size=10, + ) + + +def get_pool() -> asyncpg.Pool: + if _pool is None: + raise RuntimeError("Postgres pool not initialized") + return _pool diff --git a/projects/api/src/deps.py b/projects/api/src/deps.py new file mode 100644 index 0000000..f58cbc2 --- /dev/null +++ b/projects/api/src/deps.py @@ -0,0 +1,27 @@ +from typing import Type +from fastapi import Depends + +from src.settings import get_settings +from src.services.servers_service import ServersService + +from src.repositories.interfaces.servers import ServersRepository +from src.repositories.postgres.servers.servers import PostgresServersRepository + +REPOSITORY_REGISTRY: dict[str, Type[ServersRepository]] = { + "postgres": PostgresServersRepository, +} + +def get_servers_repository( + settings=Depends(get_settings), +) -> ServersRepository: + repo_cls = REPOSITORY_REGISTRY.get(settings.repository) + if not repo_cls: + raise RuntimeError(f"Unsupported repository type: {settings.repository}") + + return repo_cls() + + +def get_servers_service( + repo: ServersRepository = Depends(get_servers_repository), +): + return ServersService(repo) diff --git a/projects/api/src/domain/exceptions.py b/projects/api/src/domain/exceptions.py new file mode 100644 index 0000000..41800b7 --- /dev/null +++ b/projects/api/src/domain/exceptions.py @@ -0,0 +1,5 @@ +class DomainError(Exception): + """Base class for domain-level errors""" + +class InvalidIPAddress(DomainError): + pass diff --git a/projects/api/src/domain/servers.py b/projects/api/src/domain/servers.py new file mode 100644 index 0000000..398f241 --- /dev/null +++ b/projects/api/src/domain/servers.py @@ -0,0 +1,31 @@ +import ipaddress + +from dataclasses import dataclass +from typing import Literal +from src.domain.exceptions import InvalidIPAddress + + +ServerState = Literal["active", "offline", "retired"] + + +@dataclass(slots=True) +class Server: + hostname: str + ip_address: str + state: ServerState + id: int | None = None + + def activate(self) -> None: + self.state = "active" + + def deactivate(self) -> None: + self.state = "offline" + + def retire(self) -> None: + self.state = "retired" + + def __post_init__(self): + try: + ipaddress.ip_address(self.ip_address) + except ValueError: + raise InvalidIPAddress("Invalid IP address") diff --git a/projects/api/src/exceptions.py b/projects/api/src/exceptions.py new file mode 100644 index 0000000..e48f0e9 --- /dev/null +++ b/projects/api/src/exceptions.py @@ -0,0 +1,37 @@ +from fastapi import FastAPI +from fastapi.requests import Request +from fastapi.responses import JSONResponse + +from src.domain.exceptions import DomainError +from src.services.exceptions import HostnameAlreadyExists, ServerNotFound + +def register_exception_handlers(app: FastAPI) -> None: + @app.exception_handler(ServerNotFound) + async def server_not_found_handler( + request: Request, + exc: ServerNotFound, + ): + return JSONResponse( + status_code=404, + content={"detail": "Server not found"}, + ) + + @app.exception_handler(HostnameAlreadyExists) + async def hostname_exists_handler( + request: Request, + exc: HostnameAlreadyExists, + ): + return JSONResponse( + status_code=409, + content={"detail": "Hostname already exists"}, + ) + + @app.exception_handler(DomainError) + async def domain_error_handler( + request: Request, + exc: DomainError, + ): + return JSONResponse( + status_code=400, + content={"detail": str(exc)}, + ) diff --git a/projects/api/src/repositories/interfaces/servers.py b/projects/api/src/repositories/interfaces/servers.py new file mode 100644 index 0000000..be77ba8 --- /dev/null +++ b/projects/api/src/repositories/interfaces/servers.py @@ -0,0 +1,29 @@ +from abc import ABC, abstractmethod + +from src.domain.servers import Server + +class ServersRepository(ABC): + + @abstractmethod + async def create(self, server: Server) -> Server: + pass + + @abstractmethod + async def list(self) -> list[Server]: + pass + + @abstractmethod + async def get_by_id(self, server_id: int) -> Server | None: + pass + + @abstractmethod + async def update(self, server_id: int, server: Server) -> Server | None: + pass + + @abstractmethod + async def delete(self, server_id: int) -> None: + pass + + @abstractmethod + async def exists_by_hostname(self, hostname: str) -> bool: + pass diff --git a/projects/api/src/repositories/postgres/servers/queries/create.sql b/projects/api/src/repositories/postgres/servers/queries/create.sql new file mode 100644 index 0000000..f0786f1 --- /dev/null +++ b/projects/api/src/repositories/postgres/servers/queries/create.sql @@ -0,0 +1,3 @@ +INSERT INTO servers (hostname, ip_address, state) +VALUES ($1, $2, $3) +RETURNING id; diff --git a/projects/api/src/repositories/postgres/servers/queries/delete.sql b/projects/api/src/repositories/postgres/servers/queries/delete.sql new file mode 100644 index 0000000..b4747ad --- /dev/null +++ b/projects/api/src/repositories/postgres/servers/queries/delete.sql @@ -0,0 +1,3 @@ +DELETE +FROM servers +WHERE id = $1; diff --git a/projects/api/src/repositories/postgres/servers/queries/exists_by_hostname.sql b/projects/api/src/repositories/postgres/servers/queries/exists_by_hostname.sql new file mode 100644 index 0000000..38d750c --- /dev/null +++ b/projects/api/src/repositories/postgres/servers/queries/exists_by_hostname.sql @@ -0,0 +1,3 @@ +SELECT 1 +FROM servers +WHERE hostname = $1; diff --git a/projects/api/src/repositories/postgres/servers/queries/get_by_id.sql b/projects/api/src/repositories/postgres/servers/queries/get_by_id.sql new file mode 100644 index 0000000..31536a3 --- /dev/null +++ b/projects/api/src/repositories/postgres/servers/queries/get_by_id.sql @@ -0,0 +1,3 @@ +SELECT id, hostname, ip_address, state +FROM servers +WHERE id = $1; diff --git a/projects/api/src/repositories/postgres/servers/queries/list.sql b/projects/api/src/repositories/postgres/servers/queries/list.sql new file mode 100644 index 0000000..3ed8f1d --- /dev/null +++ b/projects/api/src/repositories/postgres/servers/queries/list.sql @@ -0,0 +1,2 @@ +SELECT id, hostname, ip_address, state +FROM servers; diff --git a/projects/api/src/repositories/postgres/servers/queries/update.sql b/projects/api/src/repositories/postgres/servers/queries/update.sql new file mode 100644 index 0000000..e1576b0 --- /dev/null +++ b/projects/api/src/repositories/postgres/servers/queries/update.sql @@ -0,0 +1,6 @@ +UPDATE servers +SET hostname = $1, + ip_address = $2, + state = $3 +WHERE id = $4 +RETURNING id, hostname, ip_address, state; diff --git a/projects/api/src/repositories/postgres/servers/servers.py b/projects/api/src/repositories/postgres/servers/servers.py new file mode 100644 index 0000000..d2fdb14 --- /dev/null +++ b/projects/api/src/repositories/postgres/servers/servers.py @@ -0,0 +1,71 @@ +from src.db.postgres import get_pool + +from pathlib import Path +from src.domain.servers import Server +from src.repositories.interfaces.servers import ServersRepository + +QUERIES_DIR = Path(__file__).parent / "queries" +SQL_FILE_NAME = str +SQL_QUERY = str + +def load_queries() -> dict[SQL_FILE_NAME, SQL_QUERY]: + return { + file_path.name: file_path.read_text() + for file_path in QUERIES_DIR.glob("*.sql") + } + + +class PostgresServersRepository(ServersRepository): + def __init__(self): + self._pool = get_pool() + self._queries = load_queries() + + async def create(self, server: Server) -> Server: + async with self._pool.acquire() as conn: + server_id = await conn.fetchval( + self._queries["create.sql"], + server.hostname, + server.ip_address, + server.state, + ) + server.id = server_id + return server + + async def list(self) -> list[Server]: + async with self._pool.acquire() as conn: + rows = await conn.fetch(self._queries["list.sql"]) + return [Server(**dict(row)) for row in rows] + + async def get_by_id(self, server_id: int) -> Server | None: + async with self._pool.acquire() as conn: + row = await conn.fetchrow( + self._queries["get_by_id.sql"], + server_id, + ) + return Server(**dict(row)) if row else None + + async def update(self, server_id: int, server: Server) -> Server | None: + async with self._pool.acquire() as conn: + row = await conn.fetchrow( + self._queries["update.sql"], + server.hostname, + server.ip_address, + server.state, + server_id, + ) + return Server(**dict(row)) if row else None + + async def delete(self, server_id: int) -> None: + async with self._pool.acquire() as conn: + await conn.execute( + self._queries["delete.sql"], + server_id, + ) + + async def exists_by_hostname(self, hostname: str) -> bool: + async with self._pool.acquire() as conn: + row = await conn.fetchrow( + self._queries["exists_by_hostname.sql"], + hostname, + ) + return row is not None diff --git a/projects/api/src/routes/__init__.py b/projects/api/src/routes/__init__.py new file mode 100644 index 0000000..5f252cc --- /dev/null +++ b/projects/api/src/routes/__init__.py @@ -0,0 +1,8 @@ +from fastapi import APIRouter + +from src.routes.servers.router import router as servers_router + +main_router = APIRouter() +main_router.include_router(servers_router) + +__all__ = ["main_router"] diff --git a/projects/api/src/routes/servers/router.py b/projects/api/src/routes/servers/router.py new file mode 100644 index 0000000..5a33fa4 --- /dev/null +++ b/projects/api/src/routes/servers/router.py @@ -0,0 +1,62 @@ +from fastapi import APIRouter, Depends, status + +from src.routes.servers.schemas import ( + ServerCreate, + ServerPut, + ServerResponse, +) +from src.services.servers_service import ServersService + +from src.deps import get_servers_service +from src.domain.servers import Server + +router = APIRouter( + prefix="/servers", + tags=["Servers"], +) + + +@router.post( + "", + response_model=ServerResponse, + status_code=status.HTTP_201_CREATED, +) +async def create_server( + data: ServerCreate, + service: ServersService = Depends(get_servers_service), +): + server = Server(**data.model_dump()) + return await service.create(server) + + +@router.get("", response_model=list[ServerResponse]) +async def list_servers( + service: ServersService = Depends(get_servers_service), +): + return await service.list() + + +@router.get("/{server_id}", response_model=ServerResponse) +async def get_server( + server_id: int, + service: ServersService = Depends(get_servers_service), +): + return await service.get(server_id) + + +@router.put("/{server_id}", response_model=ServerResponse) +async def update_server( + server_id: int, + data: ServerPut, + service: ServersService = Depends(get_servers_service), +): + server = Server(**data.model_dump()) + return await service.update(server_id, server) + + +@router.delete("/{server_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_server( + server_id: int, + service: ServersService = Depends(get_servers_service), +): + await service.delete(server_id) diff --git a/projects/api/src/routes/servers/schemas/__init__.py b/projects/api/src/routes/servers/schemas/__init__.py new file mode 100644 index 0000000..7301043 --- /dev/null +++ b/projects/api/src/routes/servers/schemas/__init__.py @@ -0,0 +1,8 @@ +from .requests import ServerCreate, ServerPut +from .response import ServerResponse + +__all__ = [ + "ServerCreate", + "ServerPut", + "ServerResponse", +] diff --git a/projects/api/src/routes/servers/schemas/requests.py b/projects/api/src/routes/servers/schemas/requests.py new file mode 100644 index 0000000..bf46c56 --- /dev/null +++ b/projects/api/src/routes/servers/schemas/requests.py @@ -0,0 +1,19 @@ +from pydantic import BaseModel, IPvAnyAddress, Field +from typing import Literal + + +ServerState = Literal["active", "offline", "retired"] + + +class ServerBase(BaseModel): + hostname: str = Field(..., min_length=1) + ip_address: IPvAnyAddress + state: ServerState + + +class ServerCreate(ServerBase): + pass + + +class ServerPut(ServerBase): + pass diff --git a/projects/api/src/routes/servers/schemas/response.py b/projects/api/src/routes/servers/schemas/response.py new file mode 100644 index 0000000..4602b03 --- /dev/null +++ b/projects/api/src/routes/servers/schemas/response.py @@ -0,0 +1,11 @@ +from pydantic import BaseModel, ConfigDict, IPvAnyAddress +from typing import Literal + + +class ServerResponse(BaseModel): + id: int + hostname: str + ip_address: IPvAnyAddress + state: Literal["active", "offline", "retired"] + + model_config = ConfigDict(from_attributes=True) diff --git a/projects/api/src/services/exceptions.py b/projects/api/src/services/exceptions.py new file mode 100644 index 0000000..554593c --- /dev/null +++ b/projects/api/src/services/exceptions.py @@ -0,0 +1,10 @@ +class ServiceError(Exception): + """Base service-level error""" + + +class HostnameAlreadyExists(ServiceError): + pass + + +class ServerNotFound(ServiceError): + pass diff --git a/projects/api/src/services/servers_service.py b/projects/api/src/services/servers_service.py new file mode 100644 index 0000000..347b568 --- /dev/null +++ b/projects/api/src/services/servers_service.py @@ -0,0 +1,37 @@ +from src.domain.servers import Server +from src.repositories.interfaces.servers import ServersRepository + +from src.services.exceptions import HostnameAlreadyExists, ServerNotFound + +class ServersService: + def __init__(self, repo: ServersRepository): + self._repo = repo + + async def create(self, server: Server) -> Server: + if await self._repo.exists_by_hostname(server.hostname): + raise HostnameAlreadyExists("Hostname already exists") + + return await self._repo.create(server) + + async def list(self) -> list[Server]: + return await self._repo.list() + + async def get(self, server_id: int) -> Server: + server = await self._repo.get_by_id(server_id) + if not server: + raise ServerNotFound(server_id) + + return server + + async def update(self, server_id: int, server: Server) -> Server: + if not await self._repo.get_by_id(server_id): + raise ServerNotFound(server_id) + + return await self._repo.update(server_id, server) + + async def delete(self, server_id: int) -> None: + server = await self._repo.get_by_id(server_id) + if not server: + raise ServerNotFound(server_id) + + await self._repo.delete(server_id) diff --git a/projects/api/src/settings.py b/projects/api/src/settings.py new file mode 100644 index 0000000..9e8d4ed --- /dev/null +++ b/projects/api/src/settings.py @@ -0,0 +1,17 @@ +from typing import Literal +from functools import lru_cache +from pydantic_settings import BaseSettings + +class Settings(BaseSettings): + env: Literal["dev", "stage", "prod"] + database_url: str + + host: str + port: int + + repository: Literal["postgres"] + + +@lru_cache +def get_settings() -> Settings: + return Settings() diff --git a/projects/api/uv.lock b/projects/api/uv.lock new file mode 100644 index 0000000..a4daea0 --- /dev/null +++ b/projects/api/uv.lock @@ -0,0 +1,237 @@ +version = 1 +revision = 3 +requires-python = "==3.12.*" + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, +] + +[[package]] +name = "asyncpg" +version = "0.31.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/cc/d18065ce2380d80b1bcce927c24a2642efd38918e33fd724bc4bca904877/asyncpg-0.31.0.tar.gz", hash = "sha256:c989386c83940bfbd787180f2b1519415e2d3d6277a70d9d0f0145ac73500735", size = 993667, upload-time = "2025-11-24T23:27:00.812Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/a6/59d0a146e61d20e18db7396583242e32e0f120693b67a8de43f1557033e2/asyncpg-0.31.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b44c31e1efc1c15188ef183f287c728e2046abb1d26af4d20858215d50d91fad", size = 662042, upload-time = "2025-11-24T23:25:49.578Z" }, + { url = "https://files.pythonhosted.org/packages/36/01/ffaa189dcb63a2471720615e60185c3f6327716fdc0fc04334436fbb7c65/asyncpg-0.31.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0c89ccf741c067614c9b5fc7f1fc6f3b61ab05ae4aaa966e6fd6b93097c7d20d", size = 638504, upload-time = "2025-11-24T23:25:51.501Z" }, + { url = "https://files.pythonhosted.org/packages/9f/62/3f699ba45d8bd24c5d65392190d19656d74ff0185f42e19d0bbd973bb371/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:12b3b2e39dc5470abd5e98c8d3373e4b1d1234d9fbdedf538798b2c13c64460a", size = 3426241, upload-time = "2025-11-24T23:25:53.278Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d1/a867c2150f9c6e7af6462637f613ba67f78a314b00db220cd26ff559d532/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:aad7a33913fb8bcb5454313377cc330fbb19a0cd5faa7272407d8a0c4257b671", size = 3520321, upload-time = "2025-11-24T23:25:54.982Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1a/cce4c3f246805ecd285a3591222a2611141f1669d002163abef999b60f98/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3df118d94f46d85b2e434fd62c84cb66d5834d5a890725fe625f498e72e4d5ec", size = 3316685, upload-time = "2025-11-24T23:25:57.43Z" }, + { url = "https://files.pythonhosted.org/packages/40/ae/0fc961179e78cc579e138fad6eb580448ecae64908f95b8cb8ee2f241f67/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bd5b6efff3c17c3202d4b37189969acf8927438a238c6257f66be3c426beba20", size = 3471858, upload-time = "2025-11-24T23:25:59.636Z" }, + { url = "https://files.pythonhosted.org/packages/52/b2/b20e09670be031afa4cbfabd645caece7f85ec62d69c312239de568e058e/asyncpg-0.31.0-cp312-cp312-win32.whl", hash = "sha256:027eaa61361ec735926566f995d959ade4796f6a49d3bde17e5134b9964f9ba8", size = 527852, upload-time = "2025-11-24T23:26:01.084Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f0/f2ed1de154e15b107dc692262395b3c17fc34eafe2a78fc2115931561730/asyncpg-0.31.0-cp312-cp312-win_amd64.whl", hash = "sha256:72d6bdcbc93d608a1158f17932de2321f68b1a967a13e014998db87a72ed3186", size = 597175, upload-time = "2025-11-24T23:26:02.564Z" }, +] + +[[package]] +name = "click" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "fastapi" +version = "0.128.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/08/8c8508db6c7b9aae8f7175046af41baad690771c9bcde676419965e338c7/fastapi-0.128.0.tar.gz", hash = "sha256:1cc179e1cef10a6be60ffe429f79b829dce99d8de32d7acb7e6c8dfdf7f2645a", size = 365682, upload-time = "2025-12-27T15:21:13.714Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/05/5cbb59154b093548acd0f4c7c474a118eda06da25aa75c616b72d8fcd92a/fastapi-0.128.0-py3-none-any.whl", hash = "sha256:aebd93f9716ee3b4f4fcfe13ffb7cf308d99c9f3ab5622d8877441072561582d", size = 103094, upload-time = "2025-12-27T15:21:12.154Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "inventory-management-api" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "asyncpg" }, + { name = "fastapi" }, + { name = "pydantic-settings" }, + { name = "uvicorn" }, +] + +[package.metadata] +requires-dist = [ + { name = "asyncpg", specifier = "==0.31.0" }, + { name = "fastapi", specifier = "==0.128.0" }, + { name = "pydantic-settings", specifier = "==2.12.0" }, + { name = "uvicorn", specifier = "==0.40.0" }, +] + +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/4b/ac7e0aae12027748076d72a8764ff1c9d82ca75a7a52622e67ed3f765c54/pydantic_settings-2.12.0.tar.gz", hash = "sha256:005538ef951e3c2a68e1c08b292b5f2e71490def8589d4221b95dab00dafcfd0", size = 194184, upload-time = "2025-11-10T14:25:47.013Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl", hash = "sha256:fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809", size = 51880, upload-time = "2025-11-10T14:25:45.546Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, +] + +[[package]] +name = "starlette" +version = "0.50.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ba/b8/73a0e6a6e079a9d9cfa64113d771e421640b6f679a52eeb9b32f72d871a1/starlette-0.50.0.tar.gz", hash = "sha256:a2a17b22203254bcbc2e1f926d2d55f3f9497f769416b3190768befe598fa3ca", size = 2646985, upload-time = "2025-11-01T15:25:27.516Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/52/1064f510b141bd54025f9b55105e26d1fa970b9be67ad766380a3c9b74b0/starlette-0.50.0-py3-none-any.whl", hash = "sha256:9e5391843ec9b6e472eed1365a78c8098cfceb7a74bfd4d6b1c0c0095efb3bca", size = 74033, upload-time = "2025-11-01T15:25:25.461Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.40.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/d1/8f3c683c9561a4e6689dd3b1d345c815f10f86acd044ee1fb9a4dcd0b8c5/uvicorn-0.40.0.tar.gz", hash = "sha256:839676675e87e73694518b5574fd0f24c9d97b46bea16df7b8c05ea1a51071ea", size = 81761, upload-time = "2025-12-21T14:16:22.45Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/d8/2083a1daa7439a66f3a48589a57d576aa117726762618f6bb09fe3798796/uvicorn-0.40.0-py3-none-any.whl", hash = "sha256:c6c8f55bc8bf13eb6fa9ff87ad62308bbbc33d0b67f84293151efe87e0d5f2ee", size = 68502, upload-time = "2025-12-21T14:16:21.041Z" }, +] diff --git a/projects/cli/Dockerfile b/projects/cli/Dockerfile new file mode 100644 index 0000000..e69de29 diff --git a/projects/cli/__main__.py b/projects/cli/__main__.py new file mode 100644 index 0000000..e69de29 diff --git a/projects/cli/client.py b/projects/cli/client.py new file mode 100644 index 0000000..e69de29 diff --git a/projects/cli/commands/servers.py b/projects/cli/commands/servers.py new file mode 100644 index 0000000..e69de29 diff --git a/projects/migrator/postgres/.gitignore b/projects/migrator/postgres/.gitignore new file mode 100644 index 0000000..1d17dae --- /dev/null +++ b/projects/migrator/postgres/.gitignore @@ -0,0 +1 @@ +.venv diff --git a/projects/migrator/postgres/.python-version b/projects/migrator/postgres/.python-version new file mode 100644 index 0000000..e4fba21 --- /dev/null +++ b/projects/migrator/postgres/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/projects/migrator/postgres/Dockerfile b/projects/migrator/postgres/Dockerfile new file mode 100644 index 0000000..b909852 --- /dev/null +++ b/projects/migrator/postgres/Dockerfile @@ -0,0 +1,68 @@ +################################################## +FROM python:3.12-slim AS base_builder + +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +WORKDIR /app + +RUN < Date: Sun, 18 Jan 2026 18:02:19 +0200 Subject: [PATCH 2/4] feat(cli): add Inventory CLI, setup dev/stage Docker environments, and run smoke tests - Implemented CLI utility for Inventory API with full CRUD commands. - Prepared Dockerfiles for dev and stage usage. - Integrated environment handling (API_URL) and argument validation. - Verified basic smoke tests to ensure API and CLI are operational. --- Makefile | 14 +- cli | 1 + infra/compose/.env | 2 + infra/compose/bin/stage_cli | 10 ++ infra/compose/dev.yml | 17 ++- infra/compose/stage.yml | 14 +- projects/api/Dockerfile | 34 ++--- projects/api/src/domain/exceptions.py | 1 + projects/cli/Dockerfile | 57 ++++++++ projects/cli/__main__.py | 0 projects/cli/client.py | 0 projects/cli/commands/servers.py | 0 projects/cli/main.py | 12 ++ projects/cli/pyproject.toml | 11 ++ projects/cli/src/clients/servers.py | 32 +++++ projects/cli/src/commands/servers.py | 84 ++++++++++++ projects/cli/uv.lock | 184 ++++++++++++++++++++++++++ projects/migrator/postgres/Dockerfile | 21 ++- 18 files changed, 460 insertions(+), 34 deletions(-) create mode 120000 cli create mode 100755 infra/compose/bin/stage_cli delete mode 100644 projects/cli/__main__.py delete mode 100644 projects/cli/client.py delete mode 100644 projects/cli/commands/servers.py create mode 100644 projects/cli/main.py create mode 100644 projects/cli/pyproject.toml create mode 100644 projects/cli/src/clients/servers.py create mode 100644 projects/cli/src/commands/servers.py create mode 100644 projects/cli/uv.lock diff --git a/Makefile b/Makefile index 2968cef..9a6c4b0 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,10 @@ -.PHONY: run_dev_services enter_dev_api enter_dev_mirations stop_dev_services run_stage clear_stage help +.PHONY: run_dev_services \ + enter_dev_api\ + enter_dev_migrations + enter_dev_cli \ + stop_dev_services \ + run_stage clear_stage \ + help .DEFAULT_GOAL := help @@ -16,9 +22,13 @@ enter_dev_api: ## Enter DEV API container docker compose -f infra/compose/dev.yml exec api.mathpix.com bash ## Enter dev migrator container shell -enter_dev_mirations: ## Enter DEV migrator container +enter_dev_migrations: ## Enter DEV migrator container docker compose -f infra/compose/dev.yml exec migrator.mathpix.com bash +## Enter dev cli container shell +enter_dev_cli: ## Enter DEV CLI container + docker compose -f infra/compose/dev.yml exec cli.mathpix.com bash + ## Stop and remove dev services and volumes stop_dev_services: ## Stop DEV environment and remove volumes docker compose -f infra/compose/dev.yml down --volumes diff --git a/cli b/cli new file mode 120000 index 0000000..4adc21c --- /dev/null +++ b/cli @@ -0,0 +1 @@ +infra/compose/bin/stage_cli \ No newline at end of file diff --git a/infra/compose/.env b/infra/compose/.env index 7aeb656..10ec436 100644 --- a/infra/compose/.env +++ b/infra/compose/.env @@ -4,3 +4,5 @@ POSTGRES_DB=inventory POSTGRES_HOST=inventorydb.mathpix.com DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:5432/${POSTGRES_DB} +API_PORT=8000 +API_URL=http://api.mathpix.com:${API_PORT} diff --git a/infra/compose/bin/stage_cli b/infra/compose/bin/stage_cli new file mode 100755 index 0000000..9d52bf1 --- /dev/null +++ b/infra/compose/bin/stage_cli @@ -0,0 +1,10 @@ +#!/usr/bin/env bash + +set -e -o pipefail + +SCRIPT_PATH="$(readlink -f "$0")" +SCRIPT_DIR="$(dirname "$SCRIPT_PATH")" + +cd "$SCRIPT_DIR/.." + +docker compose -f stage.yml exec cli.mathpix.com python main.py "$@" diff --git a/infra/compose/dev.yml b/infra/compose/dev.yml index e73c3a2..93023b4 100644 --- a/infra/compose/dev.yml +++ b/infra/compose/dev.yml @@ -13,7 +13,6 @@ services: - inventorydb.mathpix.com environment: DATABASE_URL: ${DATABASE_URL} - restart: no api.mathpix.com: build: @@ -27,8 +26,20 @@ services: - inventorydb.mathpix.com - migrator.mathpix.com ports: - - 8000:8000 + - ${API_PORT}:8000 environment: DATABASE_URL: ${DATABASE_URL} ENV: dev - restart: always + + cli.mathpix.com: + build: + context: ../../projects/cli + dockerfile: Dockerfile + target: dev + volumes: + - ../../projects/cli:/app + container_name: cli + depends_on: + - api.mathpix.com + environment: + API_URL: ${API_URL} diff --git a/infra/compose/stage.yml b/infra/compose/stage.yml index 40cd077..ad6a968 100644 --- a/infra/compose/stage.yml +++ b/infra/compose/stage.yml @@ -26,8 +26,20 @@ services: migrator.mathpix.com: condition: service_completed_successfully ports: - - 8000:8000 + - ${API_PORT}:8000 environment: DATABASE_URL: ${DATABASE_URL} ENV: stage restart: always + + cli.mathpix.com: + build: + context: ../../projects/cli + dockerfile: Dockerfile + target: runtime + container_name: cli + depends_on: + - api.mathpix.com + environment: + API_URL: ${API_URL} + restart: no diff --git a/projects/api/Dockerfile b/projects/api/Dockerfile index f1e14b7..be8322d 100644 --- a/projects/api/Dockerfile +++ b/projects/api/Dockerfile @@ -19,6 +19,23 @@ EOF RUN pip install --no-cache-dir uv +################################################## +FROM base_builder AS dev + +ENV PORT=8000 +ENV HOST=0.0.0.0 + +ENV REPOSITORY=postgres + +WORKDIR /app + +RUN useradd -m api +USER api + +EXPOSE 8000 + +CMD ["sleep", "infinity"] + ################################################## FROM base_builder AS runtime_builder @@ -52,20 +69,3 @@ USER api EXPOSE 8000 CMD ["python", "main.py"] - -################################################## -FROM base_builder AS dev - -ENV PORT=8000 -ENV HOST=0.0.0.0 - -ENV REPOSITORY=postgres - -WORKDIR /app - -RUN useradd -m api -USER api - -EXPOSE 8000 - -CMD ["sleep", "infinity"] diff --git a/projects/api/src/domain/exceptions.py b/projects/api/src/domain/exceptions.py index 41800b7..d7b58d7 100644 --- a/projects/api/src/domain/exceptions.py +++ b/projects/api/src/domain/exceptions.py @@ -1,5 +1,6 @@ class DomainError(Exception): """Base class for domain-level errors""" + class InvalidIPAddress(DomainError): pass diff --git a/projects/cli/Dockerfile b/projects/cli/Dockerfile index e69de29..32e8e77 100644 --- a/projects/cli/Dockerfile +++ b/projects/cli/Dockerfile @@ -0,0 +1,57 @@ +################################################## +FROM python:3.12-slim AS base_builder + +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +WORKDIR /app + +RUN < str: + api_url = os.environ.get("API_URL") + if not api_url: + print("[red]ERROR:[/] API_URL environment variable must be set.", file=sys.stderr) + raise typer.Exit(code=1) + return api_url + +client = ServersClient(get_api_url()) + + +def handle_request(func, *args, **kwargs): + """Helper to wrap HTTP calls and handle errors gracefully""" + try: + return func(*args, **kwargs) + except requests.HTTPError as e: + if e.response.status_code == 404: + print("[red]Resource not found[/]") + else: + print(f"[red]HTTP error {e.response.status_code}: {e.response.text}[/]") + raise typer.Exit(code=1) + except requests.RequestException as e: + print(f"[red]Connection error:[/] {e}") + raise typer.Exit(code=1) + + +@app.command("list") +def list_servers(): + """List all servers""" + servers = handle_request(client.list_servers) + if not servers: + print("[yellow]No servers found[/]") + return + + for s in servers: + print(f"[green]{s['id']}[/] {s['hostname']} - {s['ip_address']} ({s['state']})") + + +@app.command("get") +def get_server(server_id: int = typer.Argument(..., help="ID of the server")): + """Get a server by ID""" + s = handle_request(client.get_server, server_id) + print(f"[green]{s['id']}[/] {s['hostname']} - {s['ip_address']} ({s['state']})") + + +@app.command("create") +def create_server( + hostname: str = typer.Argument(..., help="Hostname of the server"), + ip_address: str = typer.Argument(..., help="IP address of the server"), + state: str = typer.Argument(..., help="Server state: active, offline, retired") +): + """Create a new server""" + s = handle_request(client.create_server, hostname, ip_address, state) + print(f"[green]Created[/] {s['id']} {s['hostname']}") + + +@app.command("update") +def update_server( + server_id: int = typer.Argument(..., help="ID of the server to update"), + hostname: str = typer.Argument(..., help="New hostname"), + ip_address: str = typer.Argument(..., help="New IP address"), + state: str = typer.Argument(..., help="New state: active, offline, retired") +): + """Update a server (PUT - all fields required)""" + s = handle_request(client.update_server, server_id, hostname, ip_address, state) + print(f"[yellow]Updated[/] {s['id']} {s['hostname']}") + + +@app.command("delete") +def delete_server(server_id: int = typer.Argument(..., help="ID of the server to delete")): + """Delete a server""" + handle_request(client.delete_server, server_id) + print(f"[red]Deleted server[/] {server_id}") diff --git a/projects/cli/uv.lock b/projects/cli/uv.lock new file mode 100644 index 0000000..8e19777 --- /dev/null +++ b/projects/cli/uv.lock @@ -0,0 +1,184 @@ +version = 1 +revision = 3 +requires-python = "==3.12.*" + +[[package]] +name = "certifi" +version = "2026.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" }, + { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" }, + { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" }, + { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" }, + { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" }, + { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" }, + { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" }, + { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" }, + { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" }, + { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" }, + { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" }, + { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, +] + +[[package]] +name = "click" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "inventory-management-cli" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "requests" }, + { name = "rich" }, + { name = "typer" }, +] + +[package.metadata] +requires-dist = [ + { name = "requests", specifier = "==2.32.5" }, + { name = "rich", specifier = "==14.2.0" }, + { name = "typer", specifier = "==0.21.1" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + +[[package]] +name = "rich" +version = "14.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fb/d2/8920e102050a0de7bfabeb4c4614a49248cf8d5d7a8d01885fbb24dc767a/rich-14.2.0.tar.gz", hash = "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4", size = 219990, upload-time = "2025-10-09T14:16:53.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd", size = 243393, upload-time = "2025-10-09T14:16:51.245Z" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "typer" +version = "0.21.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/36/bf/8825b5929afd84d0dabd606c67cd57b8388cb3ec385f7ef19c5cc2202069/typer-0.21.1.tar.gz", hash = "sha256:ea835607cd752343b6b2b7ce676893e5a0324082268b48f27aa058bdb7d2145d", size = 110371, upload-time = "2026-01-06T11:21:10.989Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/1d/d9257dd49ff2ca23ea5f132edf1281a0c4f9de8a762b9ae399b670a59235/typer-0.21.1-py3-none-any.whl", hash = "sha256:7985e89081c636b88d172c2ee0cfe33c253160994d47bdfdc302defd7d1f1d01", size = 47381, upload-time = "2026-01-06T11:21:09.824Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] diff --git a/projects/migrator/postgres/Dockerfile b/projects/migrator/postgres/Dockerfile index b909852..8f89ae3 100644 --- a/projects/migrator/postgres/Dockerfile +++ b/projects/migrator/postgres/Dockerfile @@ -20,6 +20,16 @@ EOF RUN pip install --no-cache-dir uv +################################################## +FROM base_builder AS dev + +WORKDIR /app + +RUN useradd -m migrator +USER migrator + +CMD ["sleep", "infinity"] + ################################################## FROM base_builder AS runtime_builder @@ -55,14 +65,3 @@ RUN useradd -m migrator USER migrator ENTRYPOINT ["python", "main.py"] - -################################################## - -FROM base_builder AS dev - -WORKDIR /app - -RUN useradd -m migrator -USER migrator - -CMD ["sleep", "infinity"] From 6a252cef6a8dfe9c9e0a5fd5a6e0f6553a933b5f Mon Sep 17 00:00:00 2001 From: Dima Kushchevskyi Date: Sun, 18 Jan 2026 19:40:13 +0200 Subject: [PATCH 3/4] test: add integration and unit tests for servers module - Added repository tests for PostgresServersRepository (create, get, list, update, delete) - Added service tests with mocks for ServersService - Added FastAPI route/controller tests with TestClient and dependency overrides - Added session-scoped DB fixture in conftest.py to initialize DB once - Added function-scoped cleanup fixture to reset DB between tests - Ensures async tests run correctly with pytest-asyncio --- Makefile | 8 ++ projects/api/.gitignore | 1 + projects/api/pyproject.toml | 7 + projects/api/pytest.ini | 5 + projects/api/tests/conftest.py | 11 ++ .../test_repositories_postgres_servers.py | 89 +++++++++++++ .../api/tests/routes/test_routes_servers.py | 78 +++++++++++ .../tests/services/test_services_servers.py | 126 ++++++++++++++++++ projects/api/uv.lock | 116 ++++++++++++++++ 9 files changed, 441 insertions(+) create mode 100644 projects/api/pytest.ini create mode 100644 projects/api/tests/conftest.py create mode 100644 projects/api/tests/repositories/postgres/test_repositories_postgres_servers.py create mode 100644 projects/api/tests/routes/test_routes_servers.py create mode 100644 projects/api/tests/services/test_services_servers.py diff --git a/Makefile b/Makefile index 9a6c4b0..00e2cac 100644 --- a/Makefile +++ b/Makefile @@ -4,6 +4,7 @@ enter_dev_cli \ stop_dev_services \ run_stage clear_stage \ + test_suite\ help .DEFAULT_GOAL := help @@ -40,3 +41,10 @@ run_stage: stop_dev_services ## Start STAGE environment ## Stop stage services and remove volumes clear_stage: ## Stop STAGE environment and remove volumes docker compose -f infra/compose/stage.yml down --volumes + +test_suite: stop_dev_services run_dev_services ## Run test suite + docker compose -f infra/compose/dev.yml exec migrator.mathpix.com uv sync --frozen + docker compose -f infra/compose/dev.yml exec migrator.mathpix.com uv run main.py + + docker compose -f infra/compose/dev.yml exec api.mathpix.com uv sync --frozen + docker compose -f infra/compose/dev.yml exec api.mathpix.com uv run uv run -- pytest diff --git a/projects/api/.gitignore b/projects/api/.gitignore index 1d17dae..8274b91 100644 --- a/projects/api/.gitignore +++ b/projects/api/.gitignore @@ -1 +1,2 @@ .venv +.pytest_cache \ No newline at end of file diff --git a/projects/api/pyproject.toml b/projects/api/pyproject.toml index e011df0..8427eec 100644 --- a/projects/api/pyproject.toml +++ b/projects/api/pyproject.toml @@ -10,3 +10,10 @@ dependencies = [ "pydantic-settings==2.12.0", "uvicorn==0.40.0", ] + +[dependency-groups] +dev = [ + "httpx==0.28.1", + "pytest==9.0.2", + "pytest-asyncio==1.3.0", +] diff --git a/projects/api/pytest.ini b/projects/api/pytest.ini new file mode 100644 index 0000000..04e61d5 --- /dev/null +++ b/projects/api/pytest.ini @@ -0,0 +1,5 @@ +[pytest] +testpaths = tests +pythonpath = . +addopts = -v --tb=short +asyncio_mode = auto diff --git a/projects/api/tests/conftest.py b/projects/api/tests/conftest.py new file mode 100644 index 0000000..e4fbae4 --- /dev/null +++ b/projects/api/tests/conftest.py @@ -0,0 +1,11 @@ +import pytest + +from src.db.db import init_db +from src.settings import get_settings + +@pytest.fixture +async def db(): + settings = get_settings() + await init_db(settings.repository, settings.database_url) + + yield diff --git a/projects/api/tests/repositories/postgres/test_repositories_postgres_servers.py b/projects/api/tests/repositories/postgres/test_repositories_postgres_servers.py new file mode 100644 index 0000000..1460522 --- /dev/null +++ b/projects/api/tests/repositories/postgres/test_repositories_postgres_servers.py @@ -0,0 +1,89 @@ +import pytest +from src.repositories.postgres.servers.servers import PostgresServersRepository +from src.domain.servers import Server +from src.db.postgres import get_pool + +from src.db.db import init_db +from src.settings import get_settings + +@pytest.fixture +async def db(): + settings = get_settings() + await init_db(settings.repository, settings.database_url) + + yield + + +@pytest.fixture +async def repo(db): + repository = PostgresServersRepository() + yield repository + + pool = await get_pool() + await pool.close() + + +@pytest.fixture(scope="function", autouse=True) +async def cleanup(repo): + pool = await get_pool() + + async with pool.acquire() as conn: + await conn.execute("TRUNCATE TABLE servers RESTART IDENTITY CASCADE;") + + yield + + async with pool.acquire() as conn: + await conn.execute("TRUNCATE TABLE servers RESTART IDENTITY CASCADE;") + + +@pytest.mark.asyncio +async def test_create_and_get_by_id(repo: PostgresServersRepository): + server = Server(id=None, hostname="s1", ip_address="192.168.1.1", state="active") + created = await repo.create(server) + + assert created.id is not None + + fetched = await repo.get_by_id(created.id) + assert fetched.id == created.id + assert fetched.hostname == "s1" + + +@pytest.mark.asyncio +async def test_list(repo: PostgresServersRepository): + server1 = Server(id=None, hostname="s1", ip_address="192.168.1.1", state="active") + server2 = Server(id=None, hostname="s2", ip_address="192.168.1.2", state="offline") + await repo.create(server1) + await repo.create(server2) + + servers = await repo.list() + assert len(servers) == 2 + assert {s.hostname for s in servers} == {"s1", "s2"} + + +@pytest.mark.asyncio +async def test_update(repo: PostgresServersRepository): + server = Server(id=None, hostname="s1", ip_address="192.168.1.1", state="active") + created = await repo.create(server) + + updated_server = Server(id=None, hostname="s1-upd", ip_address="10.0.0.1", state="retired") + result = await repo.update(created.id, updated_server) + + assert result.id == created.id + assert result.hostname == "s1-upd" + assert result.state == "retired" + + +@pytest.mark.asyncio +async def test_delete_and_exists(repo: PostgresServersRepository): + server = Server(id=None, hostname="s1", ip_address="192.168.1.1", state="active") + created = await repo.create(server) + + exists_before = await repo.exists_by_hostname("s1") + assert exists_before is True + + await repo.delete(created.id) + + exists_after = await repo.exists_by_hostname("s1") + assert exists_after is False + + assert await repo.get_by_id(created.id) is None diff --git a/projects/api/tests/routes/test_routes_servers.py b/projects/api/tests/routes/test_routes_servers.py new file mode 100644 index 0000000..d844fd6 --- /dev/null +++ b/projects/api/tests/routes/test_routes_servers.py @@ -0,0 +1,78 @@ +from fastapi import FastAPI +from fastapi.testclient import TestClient +from unittest.mock import AsyncMock + +from src.routes.servers.router import router +from src.domain.servers import Server + +# create FastAPI test app +app = FastAPI() +app.include_router(router) + +# create a mock service +mock_service = AsyncMock() + +# override dependency +from src.deps import get_servers_service +app.dependency_overrides[get_servers_service] = lambda: mock_service + +client = TestClient(app) + +server_data = { + "hostname": "server1", + "ip_address": "192.168.1.1", + "state": "active", +} + + +def test_create_server(): + mock_service.create.return_value = Server(id=1, **server_data) + + response = client.post("/servers", json=server_data) + assert response.status_code == 201 + data = response.json() + assert data["hostname"] == server_data["hostname"] + assert data["ip_address"] == server_data["ip_address"] + assert data["state"] == server_data["state"] + mock_service.create.assert_awaited_once() + + +def test_list_servers(): + mock_service.list.return_value = [ + Server(id=1, **server_data) + ] + + response = client.get("/servers") + assert response.status_code == 200 + data = response.json() + assert len(data) == 1 + assert data[0]["hostname"] == server_data["hostname"] + mock_service.list.assert_awaited_once() + + +def test_get_server(): + mock_service.get.return_value = Server(id=1, **server_data) + + response = client.get("/servers/1") + assert response.status_code == 200 + data = response.json() + assert data["hostname"] == server_data["hostname"] + mock_service.get.assert_awaited_once_with(1) + + +def test_update_server(): + updated_data = server_data.copy() + updated_data["state"] = "offline" + mock_service.update.return_value = Server(id=1, **updated_data) + + response = client.put("/servers/1", json=updated_data) + assert response.status_code == 200 + data = response.json() + assert data["state"] == "offline" + mock_service.update.assert_awaited_once() + + +def test_delete_server(): + response = client.delete("/servers/1") + assert response.status_code == 204 + mock_service.delete.assert_awaited_once_with(1) diff --git a/projects/api/tests/services/test_services_servers.py b/projects/api/tests/services/test_services_servers.py new file mode 100644 index 0000000..44fed1d --- /dev/null +++ b/projects/api/tests/services/test_services_servers.py @@ -0,0 +1,126 @@ +import pytest +from dataclasses import asdict +from unittest.mock import AsyncMock + +from src.domain.servers import Server +from src.services.servers_service import ServersService +from src.services.exceptions import HostnameAlreadyExists, ServerNotFound + + +@pytest.fixture +def mock_repo(): + repo = AsyncMock() + return repo + + +@pytest.fixture +def service(mock_repo): + return ServersService(mock_repo) + + +@pytest.mark.asyncio +async def test_create_server_success(service, mock_repo): + server = Server(id=None, hostname="s1", ip_address="192.168.1.1", state="active") + mock_repo.exists_by_hostname.return_value = False + mock_repo.create.return_value = Server( + id=1, state=server.state, hostname=server.hostname, ip_address=server.ip_address + ) + + result = await service.create(server) + + assert result.id == 1 + assert result.hostname == server.hostname + mock_repo.exists_by_hostname.assert_awaited_once_with(server.hostname) + mock_repo.create.assert_awaited_once_with(server) + + +@pytest.mark.asyncio +async def test_create_server_hostname_exists(service, mock_repo): + server = Server(id=None, hostname="s1", ip_address="192.168.1.1", state="active") + mock_repo.exists_by_hostname.return_value = True + + with pytest.raises(HostnameAlreadyExists): + await service.create(server) + + mock_repo.exists_by_hostname.assert_awaited_once_with(server.hostname) + mock_repo.create.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_list_servers(service, mock_repo): + server = Server(id=1, hostname="s1", ip_address="192.168.1.1", state="active") + mock_repo.list.return_value = [server] + + result = await service.list() + + assert len(result) == 1 + assert result[0].id == 1 + mock_repo.list.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_get_server_success(service, mock_repo): + server = Server(id=1, hostname="s1", ip_address="192.168.1.1", state="active") + mock_repo.get_by_id.return_value = server + + result = await service.get(1) + + assert result.id == 1 + mock_repo.get_by_id.assert_awaited_once_with(1) + + +@pytest.mark.asyncio +async def test_get_server_not_found(service, mock_repo): + mock_repo.get_by_id.return_value = None + + with pytest.raises(ServerNotFound): + await service.get(1) + + mock_repo.get_by_id.assert_awaited_once_with(1) + + +@pytest.mark.asyncio +async def test_update_server_success(service, mock_repo): + server = Server(id=1, hostname="s1", ip_address="192.168.1.1", state="active") + mock_repo.get_by_id.return_value = server + mock_repo.update.return_value = server + + result = await service.update(1, server) + + assert result.id == 1 + mock_repo.get_by_id.assert_awaited_once_with(1) + mock_repo.update.assert_awaited_once_with(1, server) + + +@pytest.mark.asyncio +async def test_update_server_not_found(service, mock_repo): + server = Server(id=1, hostname="s1", ip_address="192.168.1.1", state="active") + mock_repo.get_by_id.return_value = None + + with pytest.raises(ServerNotFound): + await service.update(1, server) + + mock_repo.get_by_id.assert_awaited_once_with(1) + mock_repo.update.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_delete_server_success(service, mock_repo): + server = Server(id=1, hostname="s1", ip_address="192.168.1.1", state="active") + mock_repo.get_by_id.return_value = server + + await service.delete(1) + + mock_repo.get_by_id.assert_awaited_once_with(1) + mock_repo.delete.assert_awaited_once_with(1) + + +@pytest.mark.asyncio +async def test_delete_server_not_found(service, mock_repo): + mock_repo.get_by_id.return_value = None + + with pytest.raises(ServerNotFound): + await service.delete(1) + + mock_repo.get_by_id.assert_awaited_once_with(1) + mock_repo.delete.assert_not_awaited() diff --git a/projects/api/uv.lock b/projects/api/uv.lock index a4daea0..b541402 100644 --- a/projects/api/uv.lock +++ b/projects/api/uv.lock @@ -49,6 +49,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b5/f0/f2ed1de154e15b107dc692262395b3c17fc34eafe2a78fc2115931561730/asyncpg-0.31.0-cp312-cp312-win_amd64.whl", hash = "sha256:72d6bdcbc93d608a1158f17932de2321f68b1a967a13e014998db87a72ed3186", size = 597175, upload-time = "2025-11-24T23:26:02.564Z" }, ] +[[package]] +name = "certifi" +version = "2026.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" }, +] + [[package]] name = "click" version = "8.3.1" @@ -94,6 +103,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + [[package]] name = "idna" version = "3.11" @@ -103,6 +140,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, ] +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + [[package]] name = "inventory-management-api" version = "0.1.0" @@ -114,6 +160,13 @@ dependencies = [ { name = "uvicorn" }, ] +[package.dev-dependencies] +dev = [ + { name = "httpx" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, +] + [package.metadata] requires-dist = [ { name = "asyncpg", specifier = "==0.31.0" }, @@ -122,6 +175,31 @@ requires-dist = [ { name = "uvicorn", specifier = "==0.40.0" }, ] +[package.metadata.requires-dev] +dev = [ + { name = "httpx", specifier = "==0.28.1" }, + { name = "pytest", specifier = "==9.0.2" }, + { name = "pytest-asyncio", specifier = "==1.3.0" }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + [[package]] name = "pydantic" version = "2.12.5" @@ -180,6 +258,44 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl", hash = "sha256:fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809", size = 51880, upload-time = "2025-11-10T14:25:45.546Z" }, ] +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, +] + [[package]] name = "python-dotenv" version = "1.2.1" From 0f773f179839fa8abc962a1770e7c4b02760bfed Mon Sep 17 00:00:00 2001 From: Dima Kushchevskyi Date: Sun, 18 Jan 2026 20:09:20 +0200 Subject: [PATCH 4/4] docs: add project documentation with setup, CLI, dev and stage instructions --- .gitignore | 1 + API.md | 104 +++++++++++++++++- projects/api/.gitignore | 2 +- .../src/__pycache__/settings.cpython-312.pyc | Bin 1002 -> 0 bytes projects/cli/.python-version | 1 + 5 files changed, 105 insertions(+), 3 deletions(-) create mode 100644 .gitignore delete mode 100644 projects/api/src/__pycache__/settings.cpython-312.pyc create mode 100644 projects/cli/.python-version diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bee8a64 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +__pycache__ diff --git a/API.md b/API.md index 3928d68..f4f921c 100644 --- a/API.md +++ b/API.md @@ -1,4 +1,104 @@ -# Swagger +# Prerequisites -`http://localhost:8000/docs` +To work with this project, you need: +* make +* docker & docker-compose +These tools are used for building, running, and testing the project. + +# Initial Setup + +After cloning the repository, it is recommended to run the test suite to ensure the system is working correctly: +```console +make test_suite +``` + +The test suite covers: +* API controllers +* Services +* Repositories + +# Running the Stage Environment + +The stage environment is preconfigured for demo purposes. Secrets are included in the repository. + +To start the stage environment: +```console +make run_stage +``` + +This will: +* Start the database +* Apply migrations +* Launch the FastAPI server + +Swagger documentation will be available at: +``` +http://localhost:8000/docs +``` + +You can test endpoints via Swagger or using the CLI. + +# CLI Usage + +You can test endpoints via Swagger or using the CLI. +```console +# Check available CLI commands +./cli servers --help + +# List servers +./cli servers list + +# Create a new server +./cli servers create +# Example: +./cli servers create dkushche 10.22.32.3 offline + +# Get a server by ID +./cli servers get 1 + +# Update a server (all fields are required since PUT is used) +./cli servers update 1 +# Example: +./cli servers update 1 dima 10.22.32.3 active + +# Delete a server +./cli servers delete 1 +``` + + Note: The update operation is a PUT request, so all fields must be provided; missing fields will be removed, which is not desired. + +# Development Environment + +The development environment is designed with containerized toolboxes. Containers include all necessary software but do not run any services by default. The source code is mounted via bind volumes, and FastAPI is configured with autoreload for fast development. + +Useful `make` commands: +```console +# Start all development containers +make run_dev_services + +# Enter the API container +make enter_dev_api +# Inside: +# - Start server: uv run main.py +# - Install packages: uv sync --frozen +# - Run tests: uv run -- pytest + +# Enter the migrations container +make enter_dev_migrations + +# Enter the CLI container +make enter_dev_cli +``` + +Notes: +* The database is reset between test runs using fixtures to ensure consistent test results. +* The project uses FastAPI dependency injection, pytest-asyncio, and asyncpg for asynchronous PostgreSQL interactions. + + +# Further improvements + +* Adding Linter +* Adding more sophisticated tests for API +* Improve test framework(refactore fixture recheck scoping) +* Add E2E tests diff --git a/projects/api/.gitignore b/projects/api/.gitignore index 8274b91..89c2554 100644 --- a/projects/api/.gitignore +++ b/projects/api/.gitignore @@ -1,2 +1,2 @@ .venv -.pytest_cache \ No newline at end of file +.pytest_cache diff --git a/projects/api/src/__pycache__/settings.cpython-312.pyc b/projects/api/src/__pycache__/settings.cpython-312.pyc deleted file mode 100644 index a0250d955d3b1861c98acc5d11de675544ae76d9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1002 zcmY*Y&ubGw6rS0c-E6k0O!Ce}7cCz2AcFK#z3gRNGm~^-vm0k7g@^~i z3W^tR^Zeq#6e)GNg@!t2|y!}2q8z2}D>S?lv5&B_` z$#BPH^c=_m;)vrEb#a8T^4 z!W>1;3k1RN3akmroh>`Nz(~Oer{f*>|BhEM56)C9D-!S_f$E_HCJYc7gfqsUs0fK6 zT9`{d?a7=QBC2Ms8ffJI;xk>nq$9dLHI&bI(gxn6P@fpkFu3*drjCFI~Pc5%8_PQUZ;~gK) zvUZY+Z)jx0TsAhj=;oVBwl>wcx*NTL@k-}%0nS{5PgMZ+(Qt9)lP_-omra1}(3gwW zxNf0g!9KN_V=T_HT*o@eGX)}u($bJ5)5SHpY)=+m>?=s?y8zo~>Mp|O|2s|o0Lbcf(Kg{Wq+7==;zGh=le&rQ3!yF7*7v+(4}Q5 zs_lN((s`aLX&cqd_Xa%9bke%=XR|KDu39!7`_kVkpYox+1-n+Zx(9GhFvh2-d5UhH lqB|Efz|D_uFAz-UP2|j+I~Xs0*@epBr4fOJ-*%yp_zzkK=Ia0e diff --git a/projects/cli/.python-version b/projects/cli/.python-version new file mode 100644 index 0000000..e4fba21 --- /dev/null +++ b/projects/cli/.python-version @@ -0,0 +1 @@ +3.12