init
This commit is contained in:
10
.dockerignore
Normal file
10
.dockerignore
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
.env
|
||||||
|
.venv
|
||||||
|
venv/
|
||||||
|
.git/
|
||||||
|
.gitignore
|
||||||
|
docker-compose.yml
|
||||||
|
README.md
|
||||||
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
.env
|
||||||
|
.venv
|
||||||
|
venv/
|
||||||
36
Dockerfile
Normal file
36
Dockerfile
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# ── Stage 1: builder ─────────────────────────────────────────────────────────
|
||||||
|
FROM python:3.12-slim AS builder
|
||||||
|
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
|
||||||
|
# Kompajliraj dependencies u wheel fajlove
|
||||||
|
RUN pip install --upgrade pip && \
|
||||||
|
pip wheel --no-cache-dir --wheel-dir /wheels -r requirements.txt
|
||||||
|
|
||||||
|
# ── Stage 2: production ───────────────────────────────────────────────────────
|
||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
# Ne koristiti root
|
||||||
|
RUN adduser --disabled-password --gecos "" appuser
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Kopiraj samo wheel fajlove iz buildera
|
||||||
|
COPY --from=builder /wheels /wheels
|
||||||
|
RUN pip install --no-cache-dir --no-index --find-links=/wheels /wheels/* && \
|
||||||
|
rm -rf /wheels
|
||||||
|
|
||||||
|
# Kopiraj aplikaciju
|
||||||
|
COPY app/ ./app/
|
||||||
|
|
||||||
|
# Prebaci na non-root korisnika
|
||||||
|
USER appuser
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=15s --timeout=5s --start-period=30s --retries=3 \
|
||||||
|
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"
|
||||||
|
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
39
README.md
Normal file
39
README.md
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# fastapi-todo-api
|
||||||
|
|
||||||
|
Minimalan FastAPI REST API s PostgreSQL bazom — za studentske GitOps vježbe na FPMOZ.
|
||||||
|
|
||||||
|
## Endpointi
|
||||||
|
|
||||||
|
| Metoda | URL | Opis |
|
||||||
|
|--------|-----|------|
|
||||||
|
| GET | `/health` | Status aplikacije i baze |
|
||||||
|
| GET | `/todos` | Lista svih zapisa |
|
||||||
|
| GET | `/todos/{id}` | Jedan zapis |
|
||||||
|
| POST | `/todos` | Kreiraj zapis |
|
||||||
|
| PUT | `/todos/{id}` | Ažuriraj zapis |
|
||||||
|
| DELETE | `/todos/{id}` | Obriši zapis |
|
||||||
|
|
||||||
|
Swagger dokumentacija: `http://localhost:8000/docs`
|
||||||
|
|
||||||
|
## Lokalno pokretanje
|
||||||
|
|
||||||
|
```
|
||||||
|
docker compose up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment varijable
|
||||||
|
|
||||||
|
| Varijabla | Default | Opis |
|
||||||
|
|-----------|---------|------|
|
||||||
|
| `DATABASE_HOST` | `localhost` | Hostname baze |
|
||||||
|
| `DATABASE_PORT` | `5432` | Port baze |
|
||||||
|
| `POSTGRES_USER` | `fastapiuser` | Korisnik baze |
|
||||||
|
| `POSTGRES_PASSWORD` | `student123` | Lozinka baze |
|
||||||
|
| `POSTGRES_DB` | `fastapidb` | Naziv baze |
|
||||||
|
|
||||||
|
## Build i push na Docker Hub
|
||||||
|
|
||||||
|
```
|
||||||
|
docker build -t vasic/fastapi-todo-api:1.0 .
|
||||||
|
docker push vasic/fastapi-todo-api:1.0
|
||||||
|
```
|
||||||
125
app/main.py
Normal file
125
app/main.py
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
from fastapi import FastAPI, HTTPException, Depends
|
||||||
|
from sqlalchemy import create_engine, Column, Integer, String, Boolean, text
|
||||||
|
from sqlalchemy.orm import declarative_base, Session, sessionmaker
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# ── Database connection ───────────────────────────────────────────────────────
|
||||||
|
DB_HOST = os.getenv("DATABASE_HOST", "localhost")
|
||||||
|
DB_PORT = os.getenv("DATABASE_PORT", "5432")
|
||||||
|
DB_USER = os.getenv("POSTGRES_USER", "fastapiuser")
|
||||||
|
DB_PASS = os.getenv("POSTGRES_PASSWORD", "student123")
|
||||||
|
DB_NAME = os.getenv("POSTGRES_DB", "fastapidb")
|
||||||
|
|
||||||
|
DATABASE_URL = f"postgresql://{DB_USER}:{DB_PASS}@{DB_HOST}:{DB_PORT}/{DB_NAME}"
|
||||||
|
|
||||||
|
# Retry logic — baza mozda nije odmah dostupna kad Pod starta
|
||||||
|
engine = None
|
||||||
|
for attempt in range(10):
|
||||||
|
try:
|
||||||
|
engine = create_engine(DATABASE_URL)
|
||||||
|
with engine.connect() as conn:
|
||||||
|
conn.execute(text("SELECT 1"))
|
||||||
|
log.info(f"Baza dostupna (pokusaj {attempt + 1})")
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
log.warning(f"Baza nije dostupna, cekam 3s... (pokusaj {attempt + 1}): {e}")
|
||||||
|
time.sleep(3)
|
||||||
|
|
||||||
|
if engine is None:
|
||||||
|
raise RuntimeError("Ne mogu se spojiti na bazu nakon 10 pokusaja")
|
||||||
|
|
||||||
|
SessionLocal = sessionmaker(bind=engine)
|
||||||
|
Base = declarative_base()
|
||||||
|
|
||||||
|
# ── Model ─────────────────────────────────────────────────────────────────────
|
||||||
|
class Todo(Base):
|
||||||
|
__tablename__ = "todos"
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
title = Column(String(200), nullable=False)
|
||||||
|
done = Column(Boolean, default=False)
|
||||||
|
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
|
||||||
|
# ── Schemas ───────────────────────────────────────────────────────────────────
|
||||||
|
class TodoCreate(BaseModel):
|
||||||
|
title: str
|
||||||
|
done: Optional[bool] = False
|
||||||
|
|
||||||
|
class TodoUpdate(BaseModel):
|
||||||
|
title: Optional[str] = None
|
||||||
|
done: Optional[bool] = None
|
||||||
|
|
||||||
|
class TodoOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
title: str
|
||||||
|
done: bool
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
# ── App ───────────────────────────────────────────────────────────────────────
|
||||||
|
app = FastAPI(title="FastAPI Todo API", version="1.0.0")
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
# ── Routes ────────────────────────────────────────────────────────────────────
|
||||||
|
@app.get("/health")
|
||||||
|
def health():
|
||||||
|
try:
|
||||||
|
with engine.connect() as conn:
|
||||||
|
conn.execute(text("SELECT 1"))
|
||||||
|
return {"status": "ok", "database": "connected"}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=503, detail=f"Baza nedostupna: {e}")
|
||||||
|
|
||||||
|
@app.get("/todos", response_model=list[TodoOut])
|
||||||
|
def get_todos(db: Session = Depends(get_db)):
|
||||||
|
return db.query(Todo).all()
|
||||||
|
|
||||||
|
@app.get("/todos/{todo_id}", response_model=TodoOut)
|
||||||
|
def get_todo(todo_id: int, db: Session = Depends(get_db)):
|
||||||
|
todo = db.query(Todo).filter(Todo.id == todo_id).first()
|
||||||
|
if not todo:
|
||||||
|
raise HTTPException(status_code=404, detail="Todo nije pronaden")
|
||||||
|
return todo
|
||||||
|
|
||||||
|
@app.post("/todos", response_model=TodoOut, status_code=201)
|
||||||
|
def create_todo(payload: TodoCreate, db: Session = Depends(get_db)):
|
||||||
|
todo = Todo(title=payload.title, done=payload.done)
|
||||||
|
db.add(todo)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(todo)
|
||||||
|
return todo
|
||||||
|
|
||||||
|
@app.put("/todos/{todo_id}", response_model=TodoOut)
|
||||||
|
def update_todo(todo_id: int, payload: TodoUpdate, db: Session = Depends(get_db)):
|
||||||
|
todo = db.query(Todo).filter(Todo.id == todo_id).first()
|
||||||
|
if not todo:
|
||||||
|
raise HTTPException(status_code=404, detail="Todo nije pronaden")
|
||||||
|
if payload.title is not None:
|
||||||
|
todo.title = payload.title
|
||||||
|
if payload.done is not None:
|
||||||
|
todo.done = payload.done
|
||||||
|
db.commit()
|
||||||
|
db.refresh(todo)
|
||||||
|
return todo
|
||||||
|
|
||||||
|
@app.delete("/todos/{todo_id}")
|
||||||
|
def delete_todo(todo_id: int, db: Session = Depends(get_db)):
|
||||||
|
todo = db.query(Todo).filter(Todo.id == todo_id).first()
|
||||||
|
if not todo:
|
||||||
|
raise HTTPException(status_code=404, detail="Todo nije pronaden")
|
||||||
|
db.delete(todo)
|
||||||
|
db.commit()
|
||||||
|
return {"message": f"Todo {todo_id} obrisan"}
|
||||||
36
docker-compose.yml
Normal file
36
docker-compose.yml
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
services:
|
||||||
|
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: fastapiuser
|
||||||
|
POSTGRES_PASSWORD: student123
|
||||||
|
POSTGRES_DB: fastapidb
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "pg_isready", "-U", "fastapiuser", "-d", "fastapidb"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
api:
|
||||||
|
build: .
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
environment:
|
||||||
|
DATABASE_HOST: db
|
||||||
|
DATABASE_PORT: "5432"
|
||||||
|
POSTGRES_USER: fastapiuser
|
||||||
|
POSTGRES_PASSWORD: student123
|
||||||
|
POSTGRES_DB: fastapidb
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
5
requirements.txt
Normal file
5
requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
fastapi==0.111.0
|
||||||
|
uvicorn==0.30.1
|
||||||
|
sqlalchemy==2.0.30
|
||||||
|
psycopg2-binary==2.9.9
|
||||||
|
pydantic==2.7.1
|
||||||
Reference in New Issue
Block a user