diff --git a/README.md b/README.md index bacead6..db6f177 100644 --- a/README.md +++ b/README.md @@ -22,11 +22,14 @@ cd backend python -m venv .venv source .venv/bin/activate pip install -r requirements.txt +export DATABASE_URL="postgresql+psycopg2://user:password@localhost:5432/dbname" uvicorn app:app --reload --port 5000 ``` FastAPI will run on `http://localhost:5000`. +Database health check is available at `http://localhost:5000/api/db/health`. + ## Frontend ```bash diff --git a/backend/Dockerfile b/backend/Dockerfile index c8a0e14..1645ce0 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -6,6 +6,7 @@ COPY requirements.txt ./ RUN pip install --no-cache-dir -r requirements.txt COPY app.py ./ +COPY db.py ./ EXPOSE 5000 diff --git a/backend/app.py b/backend/app.py index f4570e7..8021e75 100644 --- a/backend/app.py +++ b/backend/app.py @@ -1,5 +1,10 @@ -from fastapi import FastAPI +from fastapi import FastAPI, HTTPException 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.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("/api/hello") def hello(): 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} diff --git a/backend/db.py b/backend/db.py new file mode 100644 index 0000000..66d161e --- /dev/null +++ b/backend/db.py @@ -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) diff --git a/backend/requirements.txt b/backend/requirements.txt index 3af9cb3..7187783 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,2 +1,4 @@ fastapi==0.111.0 +psycopg2-binary==2.9.9 +SQLAlchemy==2.0.31 uvicorn[standard]==0.30.1 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..067537c --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/frontend/src/App.css b/frontend/src/App.css index d2e2a03..0a5952c 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -18,6 +18,13 @@ justify-content: center; } +.card-grid { + display: grid; + gap: 24px; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + width: min(780px, 100%); +} + .card { border: 1px solid #d9dee7; border-radius: 12px; @@ -36,3 +43,46 @@ color: #b00020; 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; +} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 933c952..7099df0 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -4,6 +4,9 @@ import "./App.css"; function App() { const [message, setMessage] = useState("loading..."); const [error, setError] = useState(""); + const [itemName, setItemName] = useState(""); + const [itemStatus, setItemStatus] = useState(""); + const [itemError, setItemError] = useState(""); useEffect(() => { 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 (
@@ -41,10 +69,34 @@ function App() {

-
-

API Response

-

{message}

- {error ?

{error}

: null} +
+
+

API Response

+

{message}

+ {error ?

{error}

: null} +
+
+

Create Item

+
+ + setItemName(event.target.value)} + placeholder="Type a name" + required + /> + +
+ {itemStatus ?

{itemStatus}

: null} + {itemError ?

{itemError}

: null} +