Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 137 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -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
97 changes: 97 additions & 0 deletions API.md
Original file line number Diff line number Diff line change
@@ -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
10 changes: 10 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
Empty file added TEST_RESULTS.md
Empty file.
Empty file added app/_init_.py
Empty file.
28 changes: 28 additions & 0 deletions app/db.py
Original file line number Diff line number Diff line change
@@ -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
67 changes: 67 additions & 0 deletions app/main.py
Original file line number Diff line number Diff line change
@@ -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
Loading