Add presigned URL and file upload functionality
All checks were successful
Build and Push Docker Images / changes (push) Successful in 4s
Build and Push Docker Images / build-backend (push) Successful in 51s
Build and Push Docker Images / build-frontend (push) Has been skipped

Implemented endpoints for generating presigned URLs and handling file uploads. Added corresponding test cases to ensure proper functionality and error handling. Updated the main router to include the new uploads API.
This commit is contained in:
2025-03-12 18:59:39 +01:00
parent e50fdb66df
commit 2993d0942c
3 changed files with 215 additions and 0 deletions

View File

@@ -3,8 +3,11 @@ from fastapi import APIRouter
from app.api.routes import auth
from app.api.routes import event_themes
from app.api.routes.events import router as events
from app.api.routes import uploads
api_router = APIRouter()
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
api_router.include_router(event_themes.router, prefix="/event_themes", tags=["event_themes"])
api_router.include_router(events.events_router, prefix="/events", tags=["events"])
api_router.include_router(uploads.router, prefix="/uploads", tags=["uploads"])

View File

@@ -0,0 +1,106 @@
# app/api/v1/uploads/router.py (refactored version)
from typing import Dict
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, status
from pydantic import BaseModel, Field
from app.api.dependencies.common import get_storage_provider
from app.api.dependencies.auth import get_current_user
from app.core.storage import StorageProvider
from app.utils.security import verify_upload_token
from app.utils.files import generate_unique_filename, get_relative_storage_path, validate_image_content_type
from app.models import User
from app.schemas.presigned_urls import PresignedUrlResponse, PresignedUrlRequest
router = APIRouter()
@router.post("/presigned-url", response_model=PresignedUrlResponse)
async def generate_presigned_url(
request: PresignedUrlRequest,
current_user: User = Depends(get_current_user),
storage: StorageProvider = Depends(get_storage_provider)
):
"""
Generate a presigned URL for uploading a file.
This endpoint creates a secure token that allows direct upload to the storage system.
After successful upload, the file will be accessible at the returned file_url.
"""
if current_user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid authentication credentials",
headers={"WWW-Authenticate": "Bearer"},
)
# Validate content type
if not validate_image_content_type(request.content_type):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Content type {request.content_type} is not allowed"
)
# Generate a unique filename
unique_filename = generate_unique_filename(request.filename)
# Create path relative to the storage root
relative_path = get_relative_storage_path(request.folder, unique_filename)
# Generate the presigned URL
expires_in = 600 # 10 minutes
upload_url, file_url = storage.generate_presigned_url(
file_path=relative_path,
filename=request.filename,
content_type=request.content_type,
expires_in=expires_in
)
return PresignedUrlResponse(
upload_url=upload_url,
file_url=file_url,
expires_in=expires_in
)
@router.post("/{token}")
async def upload_file(
token: str,
file: UploadFile = File(...),
storage: StorageProvider = Depends(get_storage_provider)
):
"""
Upload a file using a presigned URL token.
This endpoint handles the actual file upload after a presigned URL is generated.
The token validates the upload permissions and destination.
"""
# Verify the token
payload = verify_upload_token(token)
if not payload:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired upload token"
)
# Validate the content type
expected_content_type = payload["content_type"]
if file.content_type != expected_content_type:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Expected content type {expected_content_type}, got {file.content_type}"
)
# Get the destination path from the token
destination = payload["path"]
try:
# Save the file
await storage.save_file(file, destination)
# Return the file URL
return {"file_url": storage.get_file_url(destination)}
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error uploading file: {str(e)}"
)

View File

@@ -0,0 +1,106 @@
# tests/api/routes/test_uploads.py
import json
import uuid
from unittest.mock import patch, MagicMock
from datetime import datetime, timezone
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.api.routes.uploads import router as uploads_router
from app.core.database import get_db
from app.api.dependencies.auth import get_current_user
from app.api.dependencies.common import get_storage_provider
from app.core.storage import StorageProvider
@pytest.fixture
def mock_storage_provider():
"""Mock storage provider for testing."""
provider = MagicMock(spec=StorageProvider)
# Configure generate_presigned_url to return predictable values
provider.generate_presigned_url.return_value = (
"/api/v1/uploads/mock-token-123",
"/files/uploads/test-image.jpg"
)
provider.get_file_url.return_value = "/files/uploads/test-image.jpg"
return provider
@pytest.fixture
def app(db_session, mock_user, mock_storage_provider):
"""Create a FastAPI test application with overridden dependencies."""
app = FastAPI()
app.include_router(uploads_router, prefix="/api/v1/uploads", tags=["uploads"])
# Override dependencies
app.dependency_overrides[get_db] = lambda: db_session
app.dependency_overrides[get_current_user] = lambda: mock_user
app.dependency_overrides[get_storage_provider] = lambda: mock_storage_provider
return app
@pytest.fixture
def client(app):
"""Create a FastAPI test client."""
return TestClient(app)
class TestPresignedUrl:
"""Tests for the generate_presigned_url endpoint."""
def test_generate_presigned_url_success(self, client):
"""Test successful generation of presigned URL."""
# Test request
response = client.post(
"/api/v1/uploads/presigned-url",
json={
"filename": "test-image.jpg",
"content_type": "image/jpeg",
"folder": "event-themes"
}
)
# Assertions
assert response.status_code == 200
data = response.json()
assert data["upload_url"] == "/api/v1/uploads/mock-token-123"
assert data["file_url"] == "/files/uploads/test-image.jpg"
assert data["expires_in"] > 0
def test_generate_presigned_url_invalid_content_type(self, client):
"""Test generating presigned URL with invalid content type."""
# Test request with unsupported content type
response = client.post(
"/api/v1/uploads/presigned-url",
json={
"filename": "test-file.txt",
"content_type": "text/plain",
"folder": "event-themes"
}
)
# Assertions
assert response.status_code == 400
assert "not allowed" in response.json()["detail"]
def test_generate_presigned_url_unauthorized(self, app, client):
"""Test generating presigned URL without authentication."""
# Remove authentication
app.dependency_overrides[get_current_user] = lambda: None
# Test request
response = client.post(
"/api/v1/uploads/presigned-url",
json={
"filename": "test-image.jpg",
"content_type": "image/jpeg",
"folder": "event-themes"
}
)
# Assertions
assert response.status_code == 401
assert "Invalid authentication" in response.json()["detail"]