Implement Phase 1 backend: FastAPI upload API with MinIO and PostgreSQL
- Add FastAPI app with CORS and auth middleware (Bearer token) - Add POST /api/v1/transactions/upload endpoint (multipart/form-data) - Add MinIO storage service with date-partitioned object keys - Add PostgreSQL database service for transaction metadata - Add Dockerfile and docker-compose.yml for deployment - Add config.example.yaml template Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
config.yaml
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.venv/
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.env
|
||||||
13
Dockerfile
Normal file
13
Dockerfile
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY pyproject.toml .
|
||||||
|
RUN pip install --no-cache-dir .
|
||||||
|
|
||||||
|
COPY app/ app/
|
||||||
|
COPY config.example.yaml config.example.yaml
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
0
app/__init__.py
Normal file
0
app/__init__.py
Normal file
33
app/auth.py
Normal file
33
app/auth.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
from fastapi import Request
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
class AuthMiddleware(BaseHTTPMiddleware):
|
||||||
|
async def dispatch(self, request: Request, call_next):
|
||||||
|
if not request.url.path.startswith("/api/v1"):
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
token = settings.api.token
|
||||||
|
if not token:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=500,
|
||||||
|
content={"success": False, "error": "Server configuration error"},
|
||||||
|
)
|
||||||
|
|
||||||
|
auth_header = request.headers.get("Authorization")
|
||||||
|
if not auth_header or not auth_header.startswith("Bearer "):
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=401,
|
||||||
|
content={"success": False, "error": "Missing authentication token"},
|
||||||
|
)
|
||||||
|
|
||||||
|
if auth_header.removeprefix("Bearer ") != token:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=403,
|
||||||
|
content={"success": False, "error": "Invalid authentication token"},
|
||||||
|
)
|
||||||
|
|
||||||
|
return await call_next(request)
|
||||||
48
app/config.py
Normal file
48
app/config.py
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class MinioConfig(BaseModel):
|
||||||
|
endpoint: str
|
||||||
|
bucket: str = "snapledger"
|
||||||
|
access_key: str
|
||||||
|
secret_key: str
|
||||||
|
|
||||||
|
|
||||||
|
class PostgresConfig(BaseModel):
|
||||||
|
host: str
|
||||||
|
port: int = 5432
|
||||||
|
database: str = "snapledger"
|
||||||
|
user: str
|
||||||
|
password: str
|
||||||
|
|
||||||
|
@property
|
||||||
|
def dsn(self) -> str:
|
||||||
|
return (
|
||||||
|
f"postgresql://{self.user}:{self.password}"
|
||||||
|
f"@{self.host}:{self.port}/{self.database}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ApiConfig(BaseModel):
|
||||||
|
token: str
|
||||||
|
|
||||||
|
|
||||||
|
class AppConfig(BaseModel):
|
||||||
|
minio: MinioConfig
|
||||||
|
postgresql: PostgresConfig
|
||||||
|
api: ApiConfig
|
||||||
|
|
||||||
|
|
||||||
|
def load_config(path: str = "config.yaml") -> AppConfig:
|
||||||
|
config_path = Path(path)
|
||||||
|
if not config_path.exists():
|
||||||
|
raise FileNotFoundError(f"Configuration file not found: {path}")
|
||||||
|
with open(config_path, encoding="utf-8") as f:
|
||||||
|
data = yaml.safe_load(f)
|
||||||
|
return AppConfig(**data)
|
||||||
|
|
||||||
|
|
||||||
|
settings = load_config()
|
||||||
20
app/main.py
Normal file
20
app/main.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
|
from app.auth import AuthMiddleware
|
||||||
|
from app.routers import upload
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="SnapLedger API",
|
||||||
|
version="0.1.0",
|
||||||
|
)
|
||||||
|
|
||||||
|
app.add_middleware(AuthMiddleware)
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
app.include_router(upload.router, prefix="/api/v1")
|
||||||
0
app/models/__init__.py
Normal file
0
app/models/__init__.py
Normal file
7
app/models/transaction.py
Normal file
7
app/models/transaction.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class UploadResponse(BaseModel):
|
||||||
|
success: bool
|
||||||
|
data: dict | None = None
|
||||||
|
error: str | None = None
|
||||||
0
app/routers/__init__.py
Normal file
0
app/routers/__init__.py
Normal file
61
app/routers/upload.py
Normal file
61
app/routers/upload.py
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
from fastapi import APIRouter, File, Form, UploadFile
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
from app.services import database, minio
|
||||||
|
|
||||||
|
router = APIRouter(tags=["upload"])
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10 MB
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/transactions/upload")
|
||||||
|
async def upload_transaction(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
original_filename: str | None = Form(None),
|
||||||
|
user_note: str | None = Form(None),
|
||||||
|
):
|
||||||
|
# Read file content
|
||||||
|
content = await file.read()
|
||||||
|
|
||||||
|
if len(content) == 0:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=422,
|
||||||
|
content={"success": False, "error": "Missing required field: file"},
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(content) > MAX_FILE_SIZE:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=413,
|
||||||
|
content={"success": False, "error": "File size exceeds 10MB limit"},
|
||||||
|
)
|
||||||
|
|
||||||
|
filename = original_filename or file.filename or "upload.jpg"
|
||||||
|
|
||||||
|
# Upload to MinIO
|
||||||
|
try:
|
||||||
|
object_key = minio.upload_image(content, filename)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to upload image to storage")
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=503,
|
||||||
|
content={"success": False, "error": "Storage service unavailable"},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Insert metadata into PostgreSQL
|
||||||
|
try:
|
||||||
|
result = database.insert_transaction(
|
||||||
|
image_object_key=object_key,
|
||||||
|
original_filename=filename,
|
||||||
|
user_note=user_note if user_note else None,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to save transaction metadata")
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=500,
|
||||||
|
content={"success": False, "error": "Failed to save transaction metadata"},
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"success": True, "data": result}
|
||||||
0
app/services/__init__.py
Normal file
0
app/services/__init__.py
Normal file
29
app/services/database.py
Normal file
29
app/services/database.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import psycopg
|
||||||
|
from psycopg.rows import dict_row
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
def _get_conn():
|
||||||
|
return psycopg.connect(settings.postgresql.dsn, row_factory=dict_row)
|
||||||
|
|
||||||
|
|
||||||
|
def insert_transaction(
|
||||||
|
image_object_key: str,
|
||||||
|
original_filename: str | None,
|
||||||
|
user_note: str | None,
|
||||||
|
) -> dict:
|
||||||
|
with _get_conn() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO transactions
|
||||||
|
(image_object_key, original_filename, user_note, process_status)
|
||||||
|
VALUES (%s, %s, %s, 'PENDING')
|
||||||
|
RETURNING id, upload_time
|
||||||
|
""",
|
||||||
|
(image_object_key, original_filename, user_note),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
conn.commit()
|
||||||
|
return {"id": str(row["id"]), "upload_time": row["upload_time"].isoformat()}
|
||||||
41
app/services/minio.py
Normal file
41
app/services/minio.py
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import PurePosixPath
|
||||||
|
|
||||||
|
import boto3
|
||||||
|
from botocore.config import Config as BotoConfig
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
def _get_client():
|
||||||
|
return boto3.client(
|
||||||
|
"s3",
|
||||||
|
endpoint_url=settings.minio.endpoint,
|
||||||
|
aws_access_key_id=settings.minio.access_key,
|
||||||
|
aws_secret_access_key=settings.minio.secret_key,
|
||||||
|
config=BotoConfig(signature_version="s3v4"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_object_key(filename: str) -> str:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
date_prefix = now.strftime("%Y/%m/%d")
|
||||||
|
ext = PurePosixPath(filename).suffix or ".jpg"
|
||||||
|
return f"{date_prefix}/{uuid.uuid4().hex}{ext}"
|
||||||
|
|
||||||
|
|
||||||
|
def upload_image(file_bytes: bytes, original_filename: str) -> str:
|
||||||
|
client = _get_client()
|
||||||
|
object_key = _generate_object_key(original_filename)
|
||||||
|
content_type = "image/jpeg"
|
||||||
|
if original_filename.lower().endswith(".png"):
|
||||||
|
content_type = "image/png"
|
||||||
|
|
||||||
|
client.put_object(
|
||||||
|
Bucket=settings.minio.bucket,
|
||||||
|
Key=object_key,
|
||||||
|
Body=file_bytes,
|
||||||
|
ContentType=content_type,
|
||||||
|
)
|
||||||
|
return object_key
|
||||||
18
config.example.yaml
Normal file
18
config.example.yaml
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# SnapLedger Backend Configuration
|
||||||
|
# Copy this file to config.yaml and fill in your credentials.
|
||||||
|
|
||||||
|
minio:
|
||||||
|
endpoint: "https://minio.example.com:9000"
|
||||||
|
bucket: "snapledger"
|
||||||
|
access_key: "YOUR_MINIO_ACCESS_KEY"
|
||||||
|
secret_key: "YOUR_MINIO_SECRET_KEY"
|
||||||
|
|
||||||
|
postgresql:
|
||||||
|
host: "localhost"
|
||||||
|
port: 5432
|
||||||
|
database: "snapledger"
|
||||||
|
user: "snapledger_user"
|
||||||
|
password: "YOUR_POSTGRES_PASSWORD"
|
||||||
|
|
||||||
|
api:
|
||||||
|
token: "YOUR_API_TOKEN"
|
||||||
8
docker-compose.yml
Normal file
8
docker-compose.yml
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
services:
|
||||||
|
backend:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
volumes:
|
||||||
|
- ./config.yaml:/app/config.yaml:ro
|
||||||
|
restart: unless-stopped
|
||||||
20
pyproject.toml
Normal file
20
pyproject.toml
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
[project]
|
||||||
|
name = "snapledger-backend"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "SnapLedger backend API server"
|
||||||
|
requires-python = ">=3.10"
|
||||||
|
dependencies = [
|
||||||
|
"fastapi>=0.115.0",
|
||||||
|
"uvicorn[standard]>=0.34.0",
|
||||||
|
"boto3>=1.35.0",
|
||||||
|
"psycopg[binary]>=3.2.0",
|
||||||
|
"python-multipart>=0.0.12",
|
||||||
|
"pydantic>=2.9.0",
|
||||||
|
"pyyaml>=6.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"pytest>=8.0",
|
||||||
|
"httpx>=0.27",
|
||||||
|
]
|
||||||
Reference in New Issue
Block a user