Skip to content

Conversation

@xpuska513
Copy link
Collaborator

What

  • Create FastAPI for Server asset management
  • Create CLI for interacting with FastAPI app
  • Add basic documentation covering how to work with API
  • Add test suite for unit testing stuff
  • Add Make file for making life easier

How to test

  • Clone the repo
  • Checkout to this PR branch
  • Run make install
  • Run make run
  • To run tests use make test
  • To install CLI run make install-cli and then source .venv/bin/activate so you can access the binary located in the current venv

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

```bash
make install-cli
```
I will be available under active venv, to activate it run:
Copy link

Copilot AI Jan 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Grammar error: 'I will be available' should be 'It will be available' when referring to the CLI tool.

Suggested change
I will be available under active venv, to activate it run:
It will be available under the active virtual environment; to activate it, run:

Copilot uses AI. Check for mistakes.
Comment on lines +20 to +24
@property
def database_url(self) -> str:
return f"postgresql://{self.db_user}:{self.db_password}@{self.db_host}:{self.db_port}/{self.db_name}"


Copy link

Copilot AI Jan 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The database password is exposed in the database_url property, which could be logged or displayed in error messages. Consider implementing a method that masks the password in URLs meant for display or logging purposes.

Suggested change
@property
def database_url(self) -> str:
return f"postgresql://{self.db_user}:{self.db_password}@{self.db_host}:{self.db_port}/{self.db_name}"
def _build_database_url(self, mask_password: bool = False) -> str:
password = "***" if mask_password else self.db_password
return f"postgresql://{self.db_user}:{password}@{self.db_host}:{self.db_port}/{self.db_name}"
@property
def database_url(self) -> str:
"""
Database URL with the password masked.
This is intended for display or logging purposes to avoid leaking the
actual database password.
"""
return self._build_database_url(mask_password=True)
def database_url_unmasked(self) -> str:
"""
Database URL including the actual password.
Use this only when passing the URL directly to database drivers and
never log or display the returned value.
"""
return self._build_database_url(mask_password=False)

Copilot uses AI. Check for mistakes.
[project.optional-dependencies]
dev = [
"pytest>=7.0.0",
"pytest-cov>=4.0.0",
Copy link

Copilot AI Jan 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dependency version mismatch: pyproject.toml specifies pytest-cov>=4.0.0 but requirements-dev.txt specifies pytest-cov>=4.1.0. These should be consistent to avoid confusion about which version is actually required.

Suggested change
"pytest-cov>=4.0.0",
"pytest-cov>=4.1.0",

Copilot uses AI. Check for mistakes.
Comment on lines +41 to +97
def cmd_create(args):
data = {"hostname": args.hostname, "ip_address": args.ip, "state": args.state}
if args.datacenter:
data["datacenter"] = args.datacenter
result = handle_response(requests.post(f"{API_URL}/servers", json=data), 201)
if args.json:
print(json.dumps(result, indent=2, default=str))
else:
print(f"Created server {result['id']}: {result['hostname']}")


def cmd_list(args):
params = {}
if args.state:
params["state"] = args.state
if args.datacenter:
params["datacenter"] = args.datacenter
result = handle_response(requests.get(f"{API_URL}/servers", params=params))
if args.json:
print(json.dumps(result, indent=2, default=str))
else:
print(format_table(result))


def cmd_get(args):
result = handle_response(requests.get(f"{API_URL}/servers/{args.id}"))
if args.json:
print(json.dumps(result, indent=2, default=str))
else:
print(f"ID: {result['id']}")
print(f"Hostname: {result['hostname']}")
print(f"IP: {result['ip_address']}")
print(f"State: {result['state']}")
print(f"Datacenter: {result.get('datacenter') or '-'}")


def cmd_update(args):
data = {}
if args.hostname:
data["hostname"] = args.hostname
if args.ip:
data["ip_address"] = args.ip
if args.state:
data["state"] = args.state
if args.datacenter:
data["datacenter"] = args.datacenter

result = handle_response(requests.put(f"{API_URL}/servers/{args.id}", json=data))
if args.json:
print(json.dumps(result, indent=2, default=str))
else:
print(f"Updated server {result['id']}")


def cmd_delete(args):
handle_response(requests.delete(f"{API_URL}/servers/{args.id}"), 204)
print(f"Deleted server {args.id}")
Copy link

Copilot AI Jan 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing error handling for network failures and connection errors. If the API is not reachable, requests.post/get/put/delete will raise ConnectionError or Timeout exceptions that are not caught by the current error handling, potentially causing confusing stack traces for users.

Copilot uses AI. Check for mistakes.
## API Specification

Base URL: `http://localhost:8000`
Check docs at `http://localhost:8000/redoc` or `http://localhost:8000/swagger`
Copy link

Copilot AI Jan 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Documentation references swagger endpoint at /swagger but FastAPI automatically generates documentation at /docs (Swagger UI) and /redoc (ReDoc). The /swagger path doesn't exist unless explicitly configured. Line 374 correctly references /docs.

Suggested change
Check docs at `http://localhost:8000/redoc` or `http://localhost:8000/swagger`
Check docs at `http://localhost:8000/docs` or `http://localhost:8000/redoc`

Copilot uses AI. Check for mistakes.
Comment on lines +27 to +39
cursor.execute("SELECT id FROM servers WHERE hostname = %s", (server.hostname,))
if cursor.fetchone():
raise HTTPException(status_code=409, detail=f"Server '{server.hostname}' already exists")

cursor.execute("""
INSERT INTO servers (hostname, ip_address, state, datacenter)
VALUES (%s, %s, %s, %s)
RETURNING id, hostname, ip_address, state, datacenter, created_at, updated_at
""", (server.hostname, server.ip_address, server.state.value, server.datacenter))

return ServerResponse(**cursor.fetchone())


Copy link

Copilot AI Jan 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential race condition: the duplicate hostname check (line 27-29) and the insert operation (lines 31-35) are not atomic. Two concurrent requests with the same hostname could both pass the check and cause a database constraint violation. Consider using INSERT ... ON CONFLICT or implementing proper locking.

Suggested change
cursor.execute("SELECT id FROM servers WHERE hostname = %s", (server.hostname,))
if cursor.fetchone():
raise HTTPException(status_code=409, detail=f"Server '{server.hostname}' already exists")
cursor.execute("""
INSERT INTO servers (hostname, ip_address, state, datacenter)
VALUES (%s, %s, %s, %s)
RETURNING id, hostname, ip_address, state, datacenter, created_at, updated_at
""", (server.hostname, server.ip_address, server.state.value, server.datacenter))
return ServerResponse(**cursor.fetchone())
cursor.execute("""
INSERT INTO servers (hostname, ip_address, state, datacenter)
VALUES (%s, %s, %s, %s)
ON CONFLICT (hostname) DO NOTHING
RETURNING id, hostname, ip_address, state, datacenter, created_at, updated_at
""", (server.hostname, server.ip_address, server.state.value, server.datacenter))
row = cursor.fetchone()
if not row:
raise HTTPException(status_code=409, detail=f"Server '{server.hostname}' already exists")
return ServerResponse(**row)

Copilot uses AI. Check for mistakes.
Comment on lines +29 to +34
DB_HOST=localhost DB_PORT=5433 DB_NAME=inventory_test DB_USER=postgres DB_PASSWORD=postgres \
$(PYTEST) -v

test-cov:
DB_HOST=localhost DB_PORT=5433 DB_NAME=inventory_test DB_USER=postgres DB_PASSWORD=postgres \
$(PYTEST) --cov=api --cov-report=html
Copy link

Copilot AI Jan 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test and test-cov targets hardcode database environment variables. These should be sourced from docker-compose or a .env.test file to ensure consistency with the docker-compose.yml test database configuration and avoid having multiple sources of truth.

Suggested change
DB_HOST=localhost DB_PORT=5433 DB_NAME=inventory_test DB_USER=postgres DB_PASSWORD=postgres \
$(PYTEST) -v
test-cov:
DB_HOST=localhost DB_PORT=5433 DB_NAME=inventory_test DB_USER=postgres DB_PASSWORD=postgres \
$(PYTEST) --cov=api --cov-report=html
set -a; \
[ -f .env.test ] && . .env.test; \
set +a; \
$(PYTEST) -v
test-cov:
set -a; \
[ -f .env.test ] && . .env.test; \
set +a; \
$(PYTEST) --cov=api --cov-report=html

Copilot uses AI. Check for mistakes.
Comment on lines +2 to +17

from api.database import get_db_cursor, init_db
from api.models import ServerCreate, ServerUpdate, ServerResponse, ErrorResponse

app = FastAPI(
title="Server Inventory API",
description="Track servers across data centers",
version="1.0.0",
)


@app.on_event("startup")
async def startup():
init_db()


Copy link

Copilot AI Jan 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The @app.on_event decorator is deprecated in FastAPI. Use the lifespan parameter on the FastAPI instance instead. This is especially important since the PR targets future versions of FastAPI (0.104.0+).

Suggested change
from api.database import get_db_cursor, init_db
from api.models import ServerCreate, ServerUpdate, ServerResponse, ErrorResponse
app = FastAPI(
title="Server Inventory API",
description="Track servers across data centers",
version="1.0.0",
)
@app.on_event("startup")
async def startup():
init_db()
from contextlib import asynccontextmanager
from api.database import get_db_cursor, init_db
from api.models import ServerCreate, ServerUpdate, ServerResponse, ErrorResponse
@asynccontextmanager
async def lifespan(app: FastAPI):
init_db()
yield
app = FastAPI(
title="Server Inventory API",
description="Track servers across data centers",
version="1.0.0",
lifespan=lifespan,
)

Copilot uses AI. Check for mistakes.
Comment on lines +106 to +110
cursor.execute(f"""
UPDATE servers SET {', '.join(updates)}
WHERE id = %s
RETURNING *
""", params)
Copy link

Copilot AI Jan 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using f-string formatting with SQL queries is vulnerable to SQL injection attacks. While the current implementation uses parameterized queries correctly (with %s placeholders), mixing f-strings with SQL is a dangerous pattern. Consider using a static query string or a safer query builder.

Suggested change
cursor.execute(f"""
UPDATE servers SET {', '.join(updates)}
WHERE id = %s
RETURNING *
""", params)
set_clause = ", ".join(updates)
query = (
"UPDATE servers SET "
+ set_clause
+ " WHERE id = %s RETURNING *"
)
cursor.execute(query, params)

Copilot uses AI. Check for mistakes.
Comment on lines +8 to +12
os.environ.setdefault("DB_HOST", "localhost")
os.environ.setdefault("DB_PORT", "5433")
os.environ.setdefault("DB_NAME", "inventory_test")
os.environ.setdefault("DB_USER", "postgres")
os.environ.setdefault("DB_PASSWORD", "postgres")
Copy link

Copilot AI Jan 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Database credentials are hardcoded in the test configuration. While this is acceptable for a test environment, these defaults should match the docker-compose.yml test database configuration. Consider using environment variables with fallback to these defaults to maintain consistency.

Copilot uses AI. Check for mistakes.
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 19 out of 21 changed files in this pull request and generated 17 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +44 to +45
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
Copy link

Copilot AI Jan 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The database schema uses TIMESTAMP without timezone information. This can lead to ambiguity when dealing with servers across multiple data centers in different time zones. Consider using TIMESTAMP WITH TIME ZONE (TIMESTAMPTZ in PostgreSQL) to store timezone-aware timestamps.

Suggested change
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,

Copilot uses AI. Check for mistakes.
return resp.json() if expected != 204 else {}
try:
err = resp.json().get("detail", "Unknown error")
except:
Copy link

Copilot AI Jan 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The bare except clause catches all exceptions including KeyboardInterrupt and SystemExit, which should not be caught. This should be changed to catch a specific exception type like requests.JSONDecodeError or a general Exception at minimum.

Suggested change
except:
except Exception:

Copilot uses AI. Check for mistakes.
Comment on lines +21 to +32
@contextmanager
def get_db_cursor():
conn = get_connection()
try:
with conn.cursor() as cursor:
yield cursor
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
Copy link

Copilot AI Jan 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The database connection is created for every request without connection pooling. This can lead to performance issues and resource exhaustion under load. Consider using a connection pool (psycopg.ConnectionPool) or FastAPI's dependency injection with a connection pool to reuse connections.

Copilot uses AI. Check for mistakes.
@echo "make run - start stack"
@echo "make stop - stop stack"
@echo "make clean - cleanup"
@echo "make install-cli- install CLI tool"
Copy link

Copilot AI Jan 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The help text formatting is inconsistent. "make install-cli- install CLI tool" is missing a space between "install-cli" and the dash. It should be "make install-cli - install CLI tool".

Suggested change
@echo "make install-cli- install CLI tool"
@echo "make install-cli - install CLI tool"

Copilot uses AI. Check for mistakes.
Comment on lines +58 to +63
## Running Tests

1. **Start the test database:**
```bash
make test
```
Copy link

Copilot AI Jan 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The "Running Tests" section is misleading. It says "Start the test database" but the command shown is "make test" which actually runs the tests, not just starts the test database. To start just the test database, the command should be "make db-test-up" based on the Makefile. Consider clarifying this documentation or changing the heading to "Run tests".

Copilot uses AI. Check for mistakes.
Comment on lines +13 to +15
@app.on_event("startup")
async def startup():
init_db()
Copy link

Copilot AI Jan 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The on_event decorator is deprecated in FastAPI. Use the lifespan parameter with an async context manager instead. This is the recommended approach as of FastAPI 0.93+.

Copilot uses AI. Check for mistakes.
Comment on lines +99 to +101
if server.datacenter is not None:
updates.append("datacenter = %s")
params.append(server.datacenter)
Copy link

Copilot AI Jan 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The update endpoint allows setting datacenter to None by explicitly passing it in the request. However, the condition checks if server.datacenter is not None, which means an explicit None value would be treated as an update. This could lead to unintended behavior where a user cannot distinguish between "don't update datacenter" and "set datacenter to None". Consider using a sentinel value or Pydantic's Field with exclude_unset.

Copilot uses AI. Check for mistakes.
Comment on lines +18 to +22
@pytest.fixture(scope="session")
def db_connection():
conn = get_connection()
yield conn
conn.close()
Copy link

Copilot AI Jan 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The db_connection fixture is created but never used in the tests. It should either be used or removed to avoid confusion and unnecessary resource usage.

Copilot uses AI. Check for mistakes.
os.environ.setdefault("DB_PASSWORD", "postgres")

from api.main import app
from api.database import init_db, get_connection
Copy link

Copilot AI Jan 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Import of 'init_db' is not used.

Copilot uses AI. Check for mistakes.
@@ -0,0 +1,339 @@
"""Tests for API endpoints."""

import pytest
Copy link

Copilot AI Jan 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Import of 'pytest' is not used.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants