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
5 changes: 5 additions & 0 deletions .env
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
DB_USER="user"
DB_HOST="localhost"
DB_PORT=5432
DB_NAME="server-db"
DB_PASS="password123"
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
venv/*
__pycache__/*
121 changes: 121 additions & 0 deletions API.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
# Mathpix hiring challenge API
## Overview
This is a submission for the mathpix devops hiring challenge. It uses the following libraries to achieve the requirements.
- [FastAPI](https://fastapi.tiangolo.com/)
- [pytest](https://docs.pytest.org/en/stable/)
- [pydantic](https://docs.pydantic.dev/latest/)
- [asyncpg](https://magicstack.github.io/asyncpg/current/)
- [aiosql](https://aiosql.github.io/aiosql/)
- [typer](https://typer.tiangolo.com/)

The API is exposed through a docker container running FastAPI. The database connections are handled by asyncpg for simplicity and performance. For executing SQL queries, I use _almost_ raw sql through the form of aiosql.
### About aiosql
The choice to use aiosql comes down to it being effectively raw sql with a couple of extra features. aiosql allows me to have the queries written separate from the Python application logic (separation of concerns) but produces python bindings to these queries. This makes it simple to use the queries by themselves or load them in Python without much hassle.
## How to run
You'll need docker and python3 installed on your system to get started.

### Environment Setup
There is an already existing .env file included as it does not contain actual secrets. Optionally, Create a `.env` file in the project root with some environment information:
```
DB_USER="user"
DB_HOST="localhost"
DB_PORT=5432
DB_NAME="server-db"
DB_PASS="password123"
```

```bash
# Installing the python libraries
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
```

### Starting the Application
For simplicity, use the [Justfile](https://just.systems/) to startup the docker compose.
```bash
just # shows all commands with what they do
just up # starts up the application and database
just fresh # reset database and rebuild
```

## Testing
Run the test suite with pytest. Tests require the database to be running:
```bash
just fresh # reset database first
just test # run pytest
```
Note: For local testing, ensure your `.env` file has `DB_HOST=localhost` (not `postgres`).

Tests use fixtures for cleanup, so they can run multiple times without conflicts.

## API Endpoints

Base URL: `http://localhost:8000`

| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/` | Redirects to `/docs` (Swagger UI) |
| POST | `/servers` | Create a new server |
| GET | `/servers` | List all servers |
| GET | `/servers/{id}` | Get a server by ID |
| PUT | `/servers/{id}` | Update a server |
| DELETE | `/servers/{id}` | Delete a server |

### Server Model
```json
{
"id": 1,
"hostname": "web-01",
"ip": "192.168.1.100",
"state": "active",
"created_at": "2025-01-01T00:00:00"
}
```

### Create Server (POST /servers)
Request:
```json
{
"hostname": "web-01",
"ip": "192.168.1.100"
}
```
Response: `200` with server object, `409` if hostname/IP exists, `422` if invalid IP

### Update Server (PUT /servers/{id})
Request: Full server object with updated fields
Response: `200` with updated server, `404` if not found, `409` if hostname/IP conflict

### Validation
- **hostname**: Must be unique
- **ip**: Must be valid IPv4 or IPv6 address
- **state**: One of `active`, `offline`, `retired`

## CLI
The CLI tool in `cli.py` provides commands for interacting with the server API from your terminal. Available commands include:

- `create <hostname> <ip>`: Create a new server with the given hostname and IP address.
- `list`: Display all servers in a table.
- `get <id>`: Show details for a specific server by its ID.
- `update <id> [--hostname <hostname>] [--ip <ip>] [--state <state>]`: Update server fields (provide only fields you wish to change). Valid states: `active`, `offline`, `retired`.
- `delete <id>`: Remove a server by its ID.

All commands provide user-friendly output and API error messages. For further help, run:
```
python cli.py --help
```
or for a command:
```
python cli.py <command> --help
```
See code in `cli.py` for further details.

Using the cli can be done both through regular python commands and the Justfile. The CLI is managed by the [Typer](https://typer.tiangolo.com/) library. The reason for this over argparse is mostly due to it being considered the 'FastAPI of CLIs' and I wanted to give it a shot. The CLI also has some nice QOL libraries that make the terminal output nice and modern such as [rich](https://rich.readthedocs.io/en/latest/).
```bash
just cli --help #see all the different cli commands

#Both these commands are the same!
python3 cli.py list #show all servers
just cli list
```
27 changes: 27 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
FROM python:3.12-slim AS builder

WORKDIR /app

RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
&& rm -rf /var/lib/apt/lists/*

COPY requirements.txt .
RUN pip install --no-cache-dir --user -r requirements.txt

FROM python:3.12-slim

WORKDIR /app

COPY --from=builder /root/.local /root/.local

ENV PATH=/root/.local/bin:$PATH

COPY main.py models.py queries.sql ./

EXPOSE 8000

HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/')" || exit 1

CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
50 changes: 50 additions & 0 deletions Justfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
alias r := run
alias t := test
alias d := down
alias f := fresh
alias c := cli

VENV_BIN := "./venv/bin"

@_default:
just --list

run:
{{VENV_BIN}}/fastapi dev main.py

test:
{{VENV_BIN}}/pytest tests.py -v

@up:
docker compose up -d

@down:
docker compose down

@logs:
docker compose logs -f

@build:
docker compose build

@ps:
docker compose ps

@restart:
docker compose restart

@stop:
docker compose stop

@start:
docker compose start

@rebuild:
docker compose up -d --build

@fresh:
docker compose down -v
docker compose up -d --build

@cli *args:
{{VENV_BIN}}/python cli.py {{args}}
148 changes: 148 additions & 0 deletions cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import typer
import requests
import ipaddress
from typing import Optional
from rich.console import Console
from rich.table import Table
from rich import print as rprint
from models import ServerState

app = typer.Typer()
console = Console()
BASE_URL = "http://localhost:8000"


def validate_ip(ip: str) -> str:
try:
ipaddress.ip_address(ip)
except ValueError:
raise typer.BadParameter(f"Invalid IP address: {ip}")
return ip


def handle_response(response: requests.Response) -> dict:
if response.status_code >= 400:
try:
error_detail = response.json().get("detail", "Unknown error")
except:
error_detail = response.text or f"HTTP {response.status_code}"
raise typer.BadParameter(f"API error: {error_detail}")
return response.json()


def make_request(method: str, url: str, **kwargs) -> requests.Response:
try:
return requests.request(method, url, **kwargs)
except requests.exceptions.ConnectionError:
raise typer.BadParameter(
f"Could not connect to API at {BASE_URL}. "
"Make sure the API server is running."
)


@app.command()
def create(hostname: str, ip: str, owner:str):
"""Create a new server."""
validate_ip(ip)
response = make_request("POST", f"{BASE_URL}/servers", json={
"hostname": hostname, "ip": ip, "owner": owner
})
server = handle_response(response)
rprint(f"[green]✓[/green] Created server: {server['hostname']} (ID: {server['id']})")
_print_server(server)


@app.command()
def list():
"""List all servers."""
response = make_request("GET", f"{BASE_URL}/servers")
servers = handle_response(response)

if not servers:
rprint("[yellow]No servers found[/yellow]")
return

table = Table(title="Servers")
table.add_column("ID", style="cyan")
table.add_column("Hostname", style="magenta")
table.add_column("Owner", style="yellow")
table.add_column("IP", style="blue")
table.add_column("State", style="green")
table.add_column("Created At", style="dim")

for server in servers:
table.add_row(
str(server["id"]),
server["hostname"],
server["owner"],
server["ip"],
server["state"],
server["created_at"]
)

console.print(table)


@app.command()
def get(id: int):
"""Get a server by ID."""
response = make_request("GET", f"{BASE_URL}/servers/{id}")
server = handle_response(response)
_print_server(server)


@app.command()
def update(
id: int,
hostname: Optional[str] = None,
owner: Optional[str] = None,
ip: Optional[str] = None,
state: Optional[ServerState] = None
):
"""Update a server. Provide only the fields you want to update."""
if ip:
validate_ip(ip)
get_response = make_request("GET", f"{BASE_URL}/servers/{id}")
current_server = handle_response(get_response)

update_data = {
"id": current_server["id"],
"hostname": hostname or current_server["hostname"],
"owner": owner or current_server["owner"],
"ip": ip or current_server["ip"],
"state": state.value if state else current_server["state"],
"created_at": current_server["created_at"]
}

response = make_request("PUT", f"{BASE_URL}/servers/{id}", json=update_data)
server = handle_response(response)
rprint(f"[green]✓[/green] Updated server {id}")
_print_server(server)


@app.command()
def delete(id: int):
"""Delete a server by ID."""
response = make_request("DELETE", f"{BASE_URL}/servers/{id}")
server = handle_response(response)
rprint(f"[green]✓[/green] Deleted server: {server['hostname']} (ID: {server['id']})")


def _print_server(server: dict):
"""Print a single server in a formatted way."""
table = Table(show_header=False, box=None)
table.add_column("Field", style="cyan", width=12)
table.add_column("Value", style="white")

table.add_row("ID", str(server["id"]))
table.add_row("Hostname", server["hostname"])
table.add_row("Owner", server["owner"])
table.add_row("IP", server["ip"])
table.add_row("State", server["state"])
table.add_row("Created At", server["created_at"])

console.print(table)


if __name__ == "__main__":
app()
Loading