Add presigned URL and file upload functionality
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:
@@ -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"])
|
||||
|
||||
106
backend/app/api/routes/uploads.py
Normal file
106
backend/app/api/routes/uploads.py
Normal 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)}"
|
||||
)
|
||||
106
backend/tests/api/routes/test_uploads.py
Normal file
106
backend/tests/api/routes/test_uploads.py
Normal 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"]
|
||||
Reference in New Issue
Block a user