This commit is contained in:
Liam Fitzpatrick 2026-01-11 17:31:58 -05:00
parent 3b2db12fbf
commit 969f686c03
8 changed files with 231 additions and 6 deletions

View File

@ -22,11 +22,14 @@ cd backend
python -m venv .venv python -m venv .venv
source .venv/bin/activate source .venv/bin/activate
pip install -r requirements.txt pip install -r requirements.txt
export DATABASE_URL="postgresql+psycopg2://user:password@localhost:5432/dbname"
uvicorn app:app --reload --port 5000 uvicorn app:app --reload --port 5000
``` ```
FastAPI will run on `http://localhost:5000`. FastAPI will run on `http://localhost:5000`.
Database health check is available at `http://localhost:5000/api/db/health`.
## Frontend ## Frontend
```bash ```bash

View File

@ -6,6 +6,7 @@ COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
COPY app.py ./ COPY app.py ./
COPY db.py ./
EXPOSE 5000 EXPOSE 5000

View File

@ -1,5 +1,10 @@
from fastapi import FastAPI from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel, Field
from sqlalchemy import insert
from sqlalchemy.exc import SQLAlchemyError
from db import check_database_connection, create_database_tables, engine, items_table
app = FastAPI() app = FastAPI()
app.add_middleware( app.add_middleware(
@ -11,7 +16,46 @@ app.add_middleware(
) )
class ItemCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=255)
class ItemResponse(BaseModel):
id: int
name: str
@app.on_event("startup")
def on_startup() -> None:
create_database_tables()
@app.get("/hello") @app.get("/hello")
@app.get("/api/hello")
def hello(): def hello():
return {"message": "Hello from FastAPI bro"} return {"message": "Hello from FastAPI bro"}
@app.get("/db/health")
def db_health():
try:
check_database_connection()
except SQLAlchemyError as exc:
raise HTTPException(status_code=500, detail="Database connection failed") from exc
return {"status": "ok"}
@app.post("/items", response_model=ItemResponse, status_code=201)
def create_item(payload: ItemCreate):
try:
with engine.begin() as connection:
result = connection.execute(
insert(items_table)
.values(name=payload.name)
.returning(items_table.c.id, items_table.c.name)
)
row = result.one()
except SQLAlchemyError as exc:
raise HTTPException(status_code=500, detail="Failed to create item") from exc
return {"id": row.id, "name": row.name}

38
backend/db.py Normal file
View File

@ -0,0 +1,38 @@
import os
from sqlalchemy import (
Column,
DateTime,
Integer,
MetaData,
String,
Table,
create_engine,
text,
)
from sqlalchemy.sql import func
DATABASE_URL = os.getenv(
"DATABASE_URL",
"postgresql+psycopg2://postgres:postgres@localhost:5432/postgres",
)
engine = create_engine(DATABASE_URL, pool_pre_ping=True)
metadata = MetaData()
items_table = Table(
"items",
metadata,
Column("id", Integer, primary_key=True, autoincrement=True),
Column("name", String(255), nullable=False),
Column("created_at", DateTime(timezone=True), server_default=func.now()),
)
def check_database_connection() -> None:
with engine.connect() as connection:
connection.execute(text("SELECT 1"))
def create_database_tables() -> None:
metadata.create_all(engine)

View File

@ -1,2 +1,4 @@
fastapi==0.111.0 fastapi==0.111.0
psycopg2-binary==2.9.9
SQLAlchemy==2.0.31
uvicorn[standard]==0.30.1 uvicorn[standard]==0.30.1

35
docker-compose.yml Normal file
View File

@ -0,0 +1,35 @@
services:
db:
image: docker.io/library/postgres:16
restart: unless-stopped
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: postgres
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
backend:
build: ./backend
restart: unless-stopped
environment:
DATABASE_URL: postgresql+psycopg2://postgres:postgres@db:5432/postgres
ports:
- "5000:5000"
depends_on:
- db
frontend:
build: ./frontend
restart: unless-stopped
environment:
BACKEND_URL: http://backend:5000
ports:
- "5173:80"
depends_on:
- backend
volumes:
postgres_data:

View File

@ -18,6 +18,13 @@
justify-content: center; justify-content: center;
} }
.card-grid {
display: grid;
gap: 24px;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
width: min(780px, 100%);
}
.card { .card {
border: 1px solid #d9dee7; border: 1px solid #d9dee7;
border-radius: 12px; border-radius: 12px;
@ -36,3 +43,46 @@
color: #b00020; color: #b00020;
margin-top: 8px; margin-top: 8px;
} }
.form {
display: grid;
gap: 12px;
margin-top: 12px;
}
.form-label {
font-weight: 600;
color: #2a3350;
}
.form-input {
border: 1px solid #ccd3df;
border-radius: 8px;
padding: 10px 12px;
font-size: 1rem;
}
.form-input:focus {
outline: 2px solid #7f8cff;
border-color: transparent;
}
.form-button {
border: none;
border-radius: 999px;
padding: 10px 16px;
background: #1f2a44;
color: #ffffff;
font-weight: 600;
cursor: pointer;
}
.form-button:hover {
background: #111a2b;
}
.status {
margin-top: 8px;
color: #1a7f37;
font-weight: 600;
}

View File

@ -4,6 +4,9 @@ import "./App.css";
function App() { function App() {
const [message, setMessage] = useState("loading..."); const [message, setMessage] = useState("loading...");
const [error, setError] = useState(""); const [error, setError] = useState("");
const [itemName, setItemName] = useState("");
const [itemStatus, setItemStatus] = useState("");
const [itemError, setItemError] = useState("");
useEffect(() => { useEffect(() => {
let isMounted = true; let isMounted = true;
@ -32,6 +35,31 @@ function App() {
}; };
}, []); }, []);
const handleSubmit = async (event) => {
event.preventDefault();
setItemStatus("Saving...");
setItemError("");
try {
const response = await fetch("/api/items", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: itemName }),
});
if (!response.ok) {
throw new Error(`Save failed: ${response.status}`);
}
const saved = await response.json();
setItemStatus(`Saved item #${saved.id}: ${saved.name}`);
setItemName("");
} catch (err) {
setItemError(err.message);
setItemStatus("");
}
};
return ( return (
<div className="app"> <div className="app">
<header className="app-header"> <header className="app-header">
@ -41,10 +69,34 @@ function App() {
</p> </p>
</header> </header>
<main className="app-main"> <main className="app-main">
<div className="card"> <div className="card-grid">
<h2>API Response</h2> <div className="card">
<p className="message">{message}</p> <h2>API Response</h2>
{error ? <p className="error">{error}</p> : null} <p className="message">{message}</p>
{error ? <p className="error">{error}</p> : null}
</div>
<div className="card">
<h2>Create Item</h2>
<form className="form" onSubmit={handleSubmit}>
<label className="form-label" htmlFor="item-name">
Item name
</label>
<input
id="item-name"
className="form-input"
type="text"
value={itemName}
onChange={(event) => setItemName(event.target.value)}
placeholder="Type a name"
required
/>
<button className="form-button" type="submit">
Save to DB
</button>
</form>
{itemStatus ? <p className="status">{itemStatus}</p> : null}
{itemError ? <p className="error">{itemError}</p> : null}
</div>
</div> </div>
</main> </main>
</div> </div>