From 263b6dc437fe6475786f9489ace1c72ca09312dd Mon Sep 17 00:00:00 2001 From: "naheem.qureshi" Date: Sat, 20 Dec 2025 02:41:39 +0000 Subject: [PATCH] Implement CRUD API for server management with FastAPI and PostgreSQLadded token and secret variable --- .gitignore | 137 ++++++++++++++++++++++++++++++++++++++ API.md | 97 +++++++++++++++++++++++++++ Dockerfile | 10 +++ TEST_RESULTS.md | 0 app/_init_.py | 0 app/db.py | 28 ++++++++ app/main.py | 67 +++++++++++++++++++ app/repo.py | 101 ++++++++++++++++++++++++++++ app/schemas.py | 31 +++++++++ app/settings.py | 14 ++++ app/sql.py | 36 ++++++++++ cli/_init_.py | 0 cli/main.py | 101 ++++++++++++++++++++++++++++ docker-compose.yml | 29 ++++++++ migrations/001_init.sql | 8 +++ requirements.txt | 16 +++++ tests/conftest.py | 24 +++++++ tests/init.py | 0 tests/test_servers_api.py | 80 ++++++++++++++++++++++ 19 files changed, 779 insertions(+) create mode 100644 .gitignore create mode 100644 API.md create mode 100644 Dockerfile create mode 100644 TEST_RESULTS.md create mode 100644 app/_init_.py create mode 100644 app/db.py create mode 100644 app/main.py create mode 100644 app/repo.py create mode 100644 app/schemas.py create mode 100644 app/settings.py create mode 100644 app/sql.py create mode 100644 cli/_init_.py create mode 100644 cli/main.py create mode 100644 docker-compose.yml create mode 100644 migrations/001_init.sql create mode 100644 requirements.txt create mode 100644 tests/conftest.py create mode 100644 tests/init.py create mode 100644 tests/test_servers_api.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0b34bcf --- /dev/null +++ b/.gitignore @@ -0,0 +1,137 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# IDE +.vscode/ +.idea/ + +# OS +.DS_Store +Thumbs.db \ No newline at end of file diff --git a/API.md b/API.md new file mode 100644 index 0000000..42e28b2 --- /dev/null +++ b/API.md @@ -0,0 +1,97 @@ +Inventory API & CLI + +Overview + +This repo provides a FastAPI service with a PostgreSQL backend and a Typer CLI +for managing servers. The API uses raw SQL via psycopg. + +Run with Docker Compose + +1) Build and start the stack: + docker compose up --build + +2) Wait for the API to be healthy: + curl http://localhost:8000/health + +Run tests + +The test suite expects a running API. With the stack up: + + pytest + +Optionally point tests at a different API URL: + + TEST_API_URL=http://localhost:8000 pytest + +CLI usage + +The CLI talks to the running API. Default URL is http://localhost:8000 and can +be overridden with API_URL. + +Examples: + + API_URL=http://localhost:8000 python -m cli.main list + API_URL=http://localhost:8000 python -m cli.main create srv-1 10.0.0.1 active + API_URL=http://localhost:8000 python -m cli.main get 1 + API_URL=http://localhost:8000 python -m cli.main update 1 --state offline + API_URL=http://localhost:8000 python -m cli.main delete 1 + python -m cli.main --help + python -m cli.main create --help + +API spec + +Base URL: http://localhost:8000 + +Health +- GET /health -> 200 {"status": "ok"} + +Servers + +POST /servers +- Body: + { + "hostname": "srv-1", + "ip_address": "10.0.0.1", + "state": "active" + } +- Returns 201 and the created server. +- 409 if hostname already exists. +- 422 for invalid IP or state. + +GET /servers +- Query params: + - state: active|offline|retired (optional) + - limit: int (default 50, max 200) + - offset: int (default 0) +- Returns 200 with an array of servers. + +GET /servers/{id} +- Returns 200 and the server. +- 404 if not found. + +PUT /servers/{id} +- Body supports any subset of: + { + "hostname": "srv-2", + "ip_address": "10.0.0.2", + "state": "offline" + } +- Returns 200 and the updated server. +- 400 if no fields are provided. +- 404 if not found. +- 409 if hostname already exists. +- 422 for invalid IP or state. + +DELETE /servers/{id} +- Returns 204 on success. +- 404 if not found. + +Data model + +Server fields: +- id: integer +- hostname: string (unique) +- ip_address: IPv4 or IPv6 string +- state: active|offline|retired +- created_at: ISO 8601 timestamp +- updated_at: ISO 8601 timestamp diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..79b6895 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,10 @@ +FROM python:3.12-slim + +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . +EXPOSE 8000 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/TEST_RESULTS.md b/TEST_RESULTS.md new file mode 100644 index 0000000..e69de29 diff --git a/app/_init_.py b/app/_init_.py new file mode 100644 index 0000000..e69de29 diff --git a/app/db.py b/app/db.py new file mode 100644 index 0000000..aab4b2b --- /dev/null +++ b/app/db.py @@ -0,0 +1,28 @@ +from contextlib import contextmanager +from psycopg_pool import ConnectionPool +from psycopg.rows import dict_row +from .settings import settings + +pool = ConnectionPool( + conninfo=settings.database_url, + min_size=1, + max_size=10, + kwargs={"row_factory": dict_row}, +) + +@contextmanager +def get_cursor(): + """ + Context manager that: + - borrows a pooled connection + - yields a cursor + - commits if no exception, else rollbacks + """ + with pool.connection() as conn: + try: + with conn.cursor() as cur: + yield cur + conn.commit() + except Exception: + conn.rollback() + raise diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..4eae0c7 --- /dev/null +++ b/app/main.py @@ -0,0 +1,67 @@ +from fastapi import FastAPI, HTTPException, Query +from .schemas import ServerCreate, ServerUpdate, ServerOut, ServerState +from .settings import settings +from . import repo + +app = FastAPI(title="Server Inventory API") + + +@app.get("/health") +def health(): + return {"status": "ok"} + + +@app.post("/servers", response_model=ServerOut, status_code=201) +def create_server(payload: ServerCreate): + return repo.create_server( + payload.hostname, + str(payload.ip_address), + payload.state.value, + ) + + +@app.get("/servers", response_model=list[ServerOut]) +def list_servers( + state: ServerState | None = Query(default=None), + limit: int = Query(default=settings.default_limit, ge=1, le=settings.max_limit), + offset: int = Query(default=0, ge=0), +): + rows, _ = repo.list_servers( + state.value if state else None, + limit, + offset, + ) + return rows + + +@app.get("/servers/{id}", response_model=ServerOut) +def get_server(id: int): + row = repo.get_server(id) + if not row: + raise HTTPException(status_code=404, detail="server not found") + return row + + +@app.put("/servers/{id}", response_model=ServerOut) +def update_server(id: int, payload: ServerUpdate): + updates = {} + + if payload.hostname is not None: + updates["hostname"] = payload.hostname + if payload.ip_address is not None: + updates["ip_address"] = str(payload.ip_address) + if payload.state is not None: + updates["state"] = payload.state.value + + row = repo.update_server(id, updates) + if not row: + raise HTTPException(status_code=404, detail="server not found") + return row + + +@app.delete("/servers/{id}", status_code=204) +def delete_server(id: int): + row = repo.delete_server(id) + if not row: + raise HTTPException(status_code=404, detail="server not found") + return None diff --git a/app/repo.py b/app/repo.py new file mode 100644 index 0000000..3e2bf5f --- /dev/null +++ b/app/repo.py @@ -0,0 +1,101 @@ +from fastapi import HTTPException +from psycopg.errors import UniqueViolation +from .db import get_cursor + + +def create_server(hostname: str, ip_address: str, state: str): + try: + with get_cursor() as cur: + cur.execute( + """ + INSERT INTO servers (hostname, ip_address, state) + VALUES (%s, %s, %s) + RETURNING id, hostname, ip_address, state, created_at, updated_at; + """, + (hostname, ip_address, state), + ) + return cur.fetchone() + except UniqueViolation: + raise HTTPException(status_code=409, detail="hostname must be unique") + + +def get_server(server_id: int): + with get_cursor() as cur: + cur.execute( + """ + SELECT id, hostname, ip_address, state, created_at, updated_at + FROM servers + WHERE id = %s; + """, + (server_id,), + ) + return cur.fetchone() + + +def list_servers(state: str | None, limit: int, offset: int): + params = [] + where_clause = "" + + if state: + where_clause = "WHERE state = %s" + params.append(state) + + query = f""" + SELECT id, hostname, ip_address, state, created_at, updated_at + FROM servers + {where_clause} + ORDER BY id ASC + LIMIT %s OFFSET %s; + """ + + count_query = f""" + SELECT COUNT(*) AS count + FROM servers + {where_clause}; + """ + + with get_cursor() as cur: + cur.execute(query, (*params, limit, offset)) + rows = cur.fetchall() + + cur.execute(count_query, params) + total = cur.fetchone()["count"] + + return rows, total + + +def update_server(server_id: int, updates: dict): + if not updates: + raise HTTPException(status_code=400, detail="no fields provided to update") + + set_parts = [] + values = [] + + for col, val in updates.items(): + set_parts.append(f"{col} = %s") + values.append(val) + + set_clause = ", ".join(set_parts) + + query = f""" + UPDATE servers + SET {set_clause}, updated_at = now() + WHERE id = %s + RETURNING id, hostname, ip_address, state, created_at, updated_at; + """ + + try: + with get_cursor() as cur: + cur.execute(query, (*values, server_id)) + return cur.fetchone() + except UniqueViolation: + raise HTTPException(status_code=409, detail="hostname must be unique") + + +def delete_server(server_id: int): + with get_cursor() as cur: + cur.execute( + "DELETE FROM servers WHERE id = %s RETURNING id;", + (server_id,), + ) + return cur.fetchone() diff --git a/app/schemas.py b/app/schemas.py new file mode 100644 index 0000000..189a2a2 --- /dev/null +++ b/app/schemas.py @@ -0,0 +1,31 @@ +from enum import Enum +from ipaddress import IPv4Address, IPv6Address +from datetime import datetime +from pydantic import BaseModel, Field + + +class ServerState(str, Enum): + active = "active" + offline = "offline" + retired = "retired" + + +class ServerCreate(BaseModel): + hostname: str = Field(min_length=1, max_length=255) + ip_address: IPv4Address | IPv6Address + state: ServerState + + +class ServerUpdate(BaseModel): + hostname: str | None = Field(default=None, min_length=1, max_length=255) + ip_address: IPv4Address | IPv6Address | None = None + state: ServerState | None = None + + +class ServerOut(BaseModel): + id: int + hostname: str + ip_address: str + state: ServerState + created_at: datetime + updated_at: datetime diff --git a/app/settings.py b/app/settings.py new file mode 100644 index 0000000..0bc2362 --- /dev/null +++ b/app/settings.py @@ -0,0 +1,14 @@ +from pydantic_settings import BaseSettings, SettingsConfigDict + +class Settings(BaseSettings): + model_config = SettingsConfigDict(env_prefix="", case_sensitive=False) + + database_url: str = "postgresql://postgres:postgres@db:5432/mydb" + api_host: str = "0.0.0.0" + api_port: int = 8000 + + # list endpoint extras + default_limit: int = 50 + max_limit: int = 200 + +settings = Settings() diff --git a/app/sql.py b/app/sql.py new file mode 100644 index 0000000..06f2d64 --- /dev/null +++ b/app/sql.py @@ -0,0 +1,36 @@ +INSERT_SERVER = """ +INSERT INTO servers (hostname, ip_address, state) +VALUES (%s, %s, %s) +RETURNING id, hostname, ip_address, state, created_at, updated_at; +""" + +LIST_SERVERS = """ +SELECT id, hostname, ip_address, state, created_at, updated_at +FROM servers +WHERE (%s::text IS NULL OR state = %s) +ORDER BY id ASC +LIMIT %s OFFSET %s; +""" + +COUNT_SERVERS = """ +SELECT COUNT(*) AS count +FROM servers +WHERE (%s::text IS NULL OR state = %s); +""" + +GET_SERVER = """ +SELECT id, hostname, ip_address, state, created_at, updated_at +FROM servers +WHERE id = %s; +""" + +UPDATE_SERVER_TEMPLATE = """ +UPDATE servers +SET {set_clause}, updated_at = now() +WHERE id = %s +RETURNING id, hostname, ip_address, state, created_at, updated_at; +""" + +DELETE_SERVER = """ +DELETE FROM servers WHERE id = %s RETURNING id; +""" diff --git a/cli/_init_.py b/cli/_init_.py new file mode 100644 index 0000000..e69de29 diff --git a/cli/main.py b/cli/main.py new file mode 100644 index 0000000..e381efb --- /dev/null +++ b/cli/main.py @@ -0,0 +1,101 @@ +import os +import inspect +import typer +import requests +import click +from typer.core import TyperArgument, TyperOption +from rich.console import Console +from rich.table import Table + +app = typer.Typer(help="Server Inventory CLI", rich_markup_mode=None) +console = Console() + +API_URL = os.getenv("API_URL", "http://localhost:8000") + +def _call_make_metavar(func, self, ctx): + if "ctx" in inspect.signature(func).parameters: + return func(self, ctx) + return func(self) + +def _patch_typer_metavar(): + def _arg_make_metavar(self, ctx=None): + if ctx is None: + ctx = click.get_current_context(silent=True) + return _call_make_metavar(click.Argument.make_metavar, self, ctx) + + def _opt_make_metavar(self, ctx=None): + if ctx is None: + ctx = click.get_current_context(silent=True) + return _call_make_metavar(click.Option.make_metavar, self, ctx) + + TyperArgument.make_metavar = _arg_make_metavar + TyperOption.make_metavar = _opt_make_metavar + +_patch_typer_metavar() + +def die(resp: requests.Response): + try: + msg = resp.json() + except Exception: + msg = {"detail": resp.text} + console.print(f"[red]Error {resp.status_code}:[/red] {msg}") + raise typer.Exit(code=1) + +@app.command(help="Create a server.") +def create(hostname: str, ip: str, state: str): + resp = requests.post(f"{API_URL}/servers", json={"hostname": hostname, "ip_address": ip, "state": state}) + if not resp.ok: + die(resp) + console.print(resp.json()) + +@app.command(name="list", help="List servers.") +def list_cmd(state: str = None, limit: int = 50, offset: int = 0): + params = {"limit": limit, "offset": offset} + if state: + params["state"] = state + resp = requests.get(f"{API_URL}/servers", params=params) + if not resp.ok: + die(resp) + + data = resp.json() + table = Table(title="Servers") + table.add_column("id") + table.add_column("hostname") + table.add_column("ip_address") + table.add_column("state") + for s in data: + table.add_row(str(s["id"]), s["hostname"], s["ip_address"], s["state"]) + console.print(table) + +@app.command(help="Get a server by id.") +def get(id: int): + resp = requests.get(f"{API_URL}/servers/{id}") + if not resp.ok: + die(resp) + console.print(resp.json()) + +@app.command(help="Update a server by id.") +def update(id: int, hostname: str = None, ip: str = None, state: str = None): + payload = {} + if hostname is not None: + payload["hostname"] = hostname + if ip is not None: + payload["ip_address"] = ip + if state is not None: + payload["state"] = state + + resp = requests.put(f"{API_URL}/servers/{id}", json=payload) + if not resp.ok: + die(resp) + console.print(resp.json()) + +@app.command(help="Delete a server by id.") +def delete(id: int): + resp = requests.delete(f"{API_URL}/servers/{id}") + if resp.status_code == 204: + console.print("[green]Deleted[/green]") + return + die(resp) + +if __name__ == "__main__": + app() diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..0052e11 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,29 @@ + +services: + db: + image: postgres:16 + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: mydb + volumes: + - pgdata:/var/lib/postgresql/data + - ./migrations/001_init.sql:/docker-entrypoint-initdb.d/001_init.sql:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d mydb"] + interval: 3s + timeout: 3s + retries: 20 + + api: + build: . + environment: + DATABASE_URL: postgresql://postgres:postgres@db:5432/mydb + ports: + - "8000:8000" + depends_on: + db: + condition: service_healthy + +volumes: + pgdata: diff --git a/migrations/001_init.sql b/migrations/001_init.sql new file mode 100644 index 0000000..cf75810 --- /dev/null +++ b/migrations/001_init.sql @@ -0,0 +1,8 @@ +CREATE TABLE IF NOT EXISTS servers ( + id BIGSERIAL PRIMARY KEY, + hostname TEXT NOT NULL UNIQUE, + ip_address TEXT NOT NULL, + state TEXT NOT NULL CHECK (state IN ('active', 'offline', 'retired')), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..21b5cc2 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,16 @@ +fastapi==0.115.6 +uvicorn[standard]==0.34.0 + +psycopg[binary]==3.2.3 +psycopg-pool==3.2.4 + +pydantic==2.10.3 +pydantic-settings==2.6.1 + +typer==0.15.1 +requests==2.32.3 +rich==13.9.4 +click==8.1.7 + +pytest==8.3.4 +httpx==0.28.1 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..306972a --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,24 @@ +import os +import time +import pytest +import httpx + +API_URL = os.getenv("TEST_API_URL", "http://localhost:8000") + +@pytest.fixture(scope="session", autouse=True) +def wait_for_api(): + deadline = time.time() + 40 + while time.time() < deadline: + try: + r = httpx.get(f"{API_URL}/health", timeout=2) + if r.status_code == 200: + return + except Exception: + pass + time.sleep(1) + raise RuntimeError("API did not become healthy in time") + +@pytest.fixture +def client(): + with httpx.Client(base_url=API_URL, timeout=10) as client: + yield client diff --git a/tests/init.py b/tests/init.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_servers_api.py b/tests/test_servers_api.py new file mode 100644 index 0000000..46cf770 --- /dev/null +++ b/tests/test_servers_api.py @@ -0,0 +1,80 @@ +import uuid + + +def h(prefix: str) -> str: + return f"{prefix}-{uuid.uuid4()}" + + +def test_create_get_update_delete(client): + hostname = h("srv") + + r = client.post( + "/servers", + json={"hostname": hostname, "ip_address": "10.0.0.1", "state": "active"}, + ) + assert r.status_code == 201, r.text + sid = r.json()["id"] + + r = client.get(f"/servers/{sid}") + assert r.status_code == 200 + assert r.json()["hostname"] == hostname + + r = client.put(f"/servers/{sid}", json={"state": "offline"}) + assert r.status_code == 200 + assert r.json()["state"] == "offline" + + r = client.delete(f"/servers/{sid}") + assert r.status_code == 204 + + r = client.get(f"/servers/{sid}") + assert r.status_code == 404 + + +def test_hostname_unique(client): + hostname = h("unique") + + r = client.post( + "/servers", + json={"hostname": hostname, "ip_address": "10.0.0.2", "state": "active"}, + ) + assert r.status_code == 201 + + r = client.post( + "/servers", + json={"hostname": hostname, "ip_address": "10.0.0.3", "state": "offline"}, + ) + assert r.status_code == 409 + + +def test_ip_validation(client): + r = client.post( + "/servers", + json={"hostname": h("bad-ip"), "ip_address": "not-an-ip", "state": "active"}, + ) + assert r.status_code == 422 + + +def test_state_validation(client): + r = client.post( + "/servers", + json={"hostname": h("bad-state"), "ip_address": "10.0.0.4", "state": "broken"}, + ) + assert r.status_code == 422 + + +def test_list_filter_pagination(client): + client.post( + "/servers", + json={"hostname": h("srv-a"), "ip_address": "10.0.1.1", "state": "active"}, + ) + client.post( + "/servers", + json={"hostname": h("srv-b"), "ip_address": "10.0.1.2", "state": "retired"}, + ) + + r = client.get( + "/servers", + params={"state": "retired", "limit": 10, "offset": 0}, + ) + assert r.status_code == 200 + assert all(s["state"] == "retired" for s in r.json())