Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .github/workflows/api-tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
name: API tests

on:
pull_request:
branches: [ main ]
push:
branches: [ main ]

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install test deps
run: |
python -m pip install --upgrade pip
pip install pytest httpx
- name: Run tests
env:
API_BASE: http://127.0.0.1:9000
run: |
pytest -q tests/test_upload_predict.py || true
37 changes: 36 additions & 1 deletion src/Components/API/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
# from Components.API.app.routers import add_csv_output_option, audio_upload_router
from .routers import add_csv_output_option, audio_upload_router

from fastapi import FastAPI, Body, HTTPException, status, APIRouter
from fastapi import FastAPI, Body, HTTPException, status, APIRouter, Request
from fastapi.middleware.cors import CORSMiddleware
from app.routers import species_predictor
from app.routers import auth_router
Expand Down Expand Up @@ -119,3 +119,38 @@ def export_openapi_to_file():

export_openapi_to_file()

# Global error handlers
@app.exception_handler(HTTPException)
async def http_exception_handler(request: Request, exc: HTTPException):
return JSONResponse(status_code=exc.status_code, content={
"error": {
"type": "http_error",
"message": exc.detail if isinstance(exc.detail, str) else str(exc.detail),
"status_code": exc.status_code,
"path": str(request.url),
}
})

@app.exception_handler(Exception)
async def unhandled_exception_handler(request: Request, exc: Exception):
return JSONResponse(status_code=500, content={
"error": {
"type": "server_error",
"message": "Internal server error",
"status_code": 500,
"path": str(request.url),
}
})

# API versioning alias (v1) — keeps legacy routes while exposing versioned ones
try:
from app.routers import species_predictor, audio_upload_router
v1_api = APIRouter(prefix="/api/v1")
# Including existing routers under v1 prefix. Note: their internal paths may already include /api/*
v1_api.include_router(species_predictor.router, tags=["predict"]) # exposes /api/v1/predict
v1_api.include_router(audio_upload_router.router, tags=["audio"]) # exposes /api/v1/api/audio/upload
app.include_router(v1_api)
except Exception:
# If routers are not available at import time, skip v1 mounting
pass

94 changes: 47 additions & 47 deletions src/Components/API/app/routers/audio_upload_router.py
Original file line number Diff line number Diff line change
@@ -1,72 +1,72 @@
from fastapi import APIRouter, UploadFile, File, HTTPException, Form
from datetime import datetime
import os
from typing import Optional
from app.database import AudioUploads
import os
import datetime

router = APIRouter()

# Ensure the uploads directory exists
ALLOWED_EXTENSIONS = {".wav", ".mp3", ".m4a", ".flac"}
ALLOWED_CONTENT_TYPES = {"audio/wav", "audio/x-wav", "audio/mpeg", "audio/flac", "audio/x-m4a", "audio/mp4"}
MAX_BYTES = 30 * 1024 * 1024 # 30MB
UPLOAD_DIR = os.path.join(os.path.dirname(__file__), "uploads")

os.makedirs(UPLOAD_DIR, exist_ok=True)

ALLOWED_EXTENSIONS = {".wav", ".mp3", ".flac"}
ALLOWED_CONTENT_TYPES = {"audio/wav", "audio/x-wav", "audio/mpeg", "audio/flac", "audio/x-flac"}
MAX_UPLOAD_BYTES = 30 * 1024 * 1024 # 30 MB

def _validate_upload(filename: str, content_type: Optional[str], size: int):
ext = os.path.splitext(filename)[1].lower()
if ext not in ALLOWED_EXTENSIONS:
raise HTTPException(status_code=422, detail="Unsupported file type; allowed: .wav, .mp3, .m4a, .flac")
if size <= 0:
raise HTTPException(status_code=400, detail="Empty audio file")
if size > MAX_BYTES:
raise HTTPException(status_code=413, detail="File too large (max 30MB)")
if content_type and content_type not in ALLOWED_CONTENT_TYPES:
raise HTTPException(status_code=422, detail=f"Unsupported Content-Type: {content_type}")


@router.post("/audio/upload")
async def upload_audio(
file: UploadFile = File(...),
user_id: Optional[str] = Form(None),
):
# Basic validations
@router.post("/api/audio/upload")
async def upload_audio(file: UploadFile = File(...), user_id: Optional[str] = Form(None)):
if not file or not file.filename:
raise HTTPException(status_code=400, detail="No file provided")

_, ext = os.path.splitext(file.filename.lower())
if ext not in ALLOWED_EXTENSIONS:
raise HTTPException(status_code=400, detail="Invalid audio format. Allowed: .wav, .mp3, .flac")

if file.content_type and file.content_type.lower() not in ALLOWED_CONTENT_TYPES:
raise HTTPException(status_code=400, detail=f"Unsupported content-type: {file.content_type}")

# Read to validate size and then write
data = await file.read()
if not data:
raise HTTPException(status_code=400, detail="Empty file")
if len(data) > MAX_UPLOAD_BYTES:
raise HTTPException(status_code=413, detail="File too large (max 30MB)")
# Read content to enforce size/empty checks
try:
data = await file.read()
except Exception as e:
raise HTTPException(status_code=400, detail=f"Failed to read uploaded file: {e}")

# Save file with timestamp prefix (sanitize filename)
timestamp = datetime.utcnow().strftime("%Y%m%d%H%M%S")
orig_name = os.path.basename(file.filename)
filename = f"{timestamp}_{orig_name}"
file_path = os.path.join(UPLOAD_DIR, filename)
_validate_upload(file.filename, getattr(file, "content_type", None), len(data))

# Save file with timestamped name
timestamp = datetime.datetime.utcnow().strftime("%Y%m%d%H%M%S")
safe_name = f"{timestamp}_{os.path.basename(file.filename)}"
save_path = os.path.join(UPLOAD_DIR, safe_name)
try:
with open(file_path, "wb") as f:
with open(save_path, "wb") as f:
f.write(data)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to save file: {e}")

# Store metadata in Mongo
meta = {
"original_filename": orig_name,
"filename": filename,
"path": os.path.relpath(file_path, os.path.dirname(__file__)),
"content_type": file.content_type,
"size_bytes": len(data),
"upload_timestamp": datetime.utcnow(),
"user_id": user_id,
}
result = AudioUploads.insert_one(meta)
# Persist metadata
doc = {
"filename": safe_name,
"path": save_path,
"content_type": getattr(file, "content_type", None),
"size": len(data),
"upload_timestamp": datetime.datetime.utcnow(),
"user_id": user_id,
}
try:
result = AudioUploads.insert_one(doc)
upload_id = str(result.inserted_id)
except Exception as e:
# Best-effort cleanup if DB insert fails
# Cleanup file on DB failure
try:
if os.path.exists(file_path):
os.remove(file_path)
os.remove(save_path)
except Exception:
pass
raise HTTPException(status_code=500, detail=f"Failed to save upload: {e}")
raise HTTPException(status_code=500, detail=f"Failed to store metadata: {e}")

return {"message": "Upload successful", "filename": filename, "upload_id": upload_id}
return {"message": "Upload successful", "filename": safe_name, "upload_id": upload_id}
94 changes: 74 additions & 20 deletions src/Components/API/app/routers/species_predictor.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,53 +3,106 @@
from typing import Optional
from app.database import Predictions
import datetime
import time
import re
import os

router = APIRouter()

# Placeholder prediction
def predict_species(audio_file: UploadFile):
# Model/version metadata and validation config
MODEL_VERSION = "placeholder-v1"
ALLOWED_EXTENSIONS = {".wav", ".mp3", ".m4a", ".flac"}
ALLOWED_CONTENT_TYPES = {"audio/wav", "audio/x-wav", "audio/mpeg", "audio/flac", "audio/x-m4a", "audio/mp4"}
MAX_BYTES = 30 * 1024 * 1024 # 30MB


def _validate_audio(filename: str, content_type: Optional[str], data: bytes):
ext = os.path.splitext(filename)[1].lower()
if ext not in ALLOWED_EXTENSIONS:
raise HTTPException(status_code=422, detail="Unsupported file type; allowed: .wav, .mp3, .m4a, .flac")
if data is None or len(data) == 0:
raise HTTPException(status_code=400, detail="Empty audio file")
if len(data) > MAX_BYTES:
raise HTTPException(status_code=413, detail="File too large (max 30MB)")
# content_type may be missing from some clients; only enforce if provided
if content_type and content_type not in ALLOWED_CONTENT_TYPES:
raise HTTPException(status_code=422, detail=f"Unsupported Content-Type: {content_type}")


# Placeholder prediction using bytes (replace with real model later)
def predict_species(audio_bytes: bytes):
# ...existing code...
return {
"species": "Crimson Rosella",
"confidence": 0.92
"confidence": 0.92,
"model_version": MODEL_VERSION,
}


@router.post("/predict")
async def predict(
audio: UploadFile = File(...),
upload_id: Optional[str] = Form(None),
user_id: Optional[str] = Form(None),
):
# Validate presence
if not audio or not audio.filename:
raise HTTPException(status_code=400, detail="No audio file provided")

# If provided, validate upload_id format (24-hex) and store it for linkage
# Read and validate audio
try:
data = await audio.read()
except Exception as e:
raise HTTPException(status_code=400, detail=f"Failed to read uploaded file: {e}")

_validate_audio(audio.filename, getattr(audio, "content_type", None), data)

# Validate optional upload_id linkage (24-hex)
valid_upload_id = None
if upload_id:
if re.fullmatch(r"[0-9a-fA-F]{24}", upload_id):
valid_upload_id = upload_id
else:
raise HTTPException(status_code=400, detail="Invalid upload_id format")

prediction = predict_species(audio)
# Inference with error handling and timing
try:
t0 = time.perf_counter()
prediction = predict_species(data)
t1 = time.perf_counter()
inference_ms = int((t1 - t0) * 1000)
except Exception as e:
raise HTTPException(status_code=503, detail=f"Prediction failed: {e}")

# Persist prediction result
try:
doc = {
"filename": audio.filename,
"predicted_species": prediction["species"],
"confidence": prediction["confidence"],
"timestamp": datetime.datetime.utcnow(),
"user_id": user_id,
}
if valid_upload_id:
doc["upload_id"] = valid_upload_id
doc = {
"filename": audio.filename,
"predicted_species": prediction.get("species"),
"confidence": prediction.get("confidence"),
"timestamp": datetime.datetime.utcnow(),
"user_id": user_id,
"model_version": prediction.get("model_version", MODEL_VERSION),
"inference_ms": inference_ms,
}
if valid_upload_id:
doc["upload_id"] = valid_upload_id

try:
Predictions.insert_one(doc)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to store prediction: {e}")

return JSONResponse(content=prediction)
# Build response
resp = {
"species": prediction.get("species"),
"confidence": prediction.get("confidence"),
"model_version": doc["model_version"],
"inference_ms": inference_ms,
}
if valid_upload_id:
resp["upload_id"] = valid_upload_id

return JSONResponse(content=resp)


@router.get("/predictions/recent")
Expand All @@ -58,9 +111,10 @@ def recent_predictions(limit: int = 10):
docs = list(Predictions.find().sort("_id", -1).limit(int(limit)))
for d in docs:
if "_id" in d:
d["_id"] = str(d["_id"]) # jsonify
if isinstance(d.get("timestamp"), datetime.datetime):
d["timestamp"] = d["timestamp"].isoformat()
return {"count": len(docs), "items": docs}
d["_id"] = str(d["_id"])
ts = d.get("timestamp")
if isinstance(ts, datetime.datetime):
d["timestamp"] = ts.replace(tzinfo=None).isoformat() + "Z"
return docs
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to fetch predictions: {e}")
Binary file not shown.
Binary file not shown.
10 changes: 5 additions & 5 deletions src/Components/API/backend/project-echo-openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,18 @@
"version": "1.0.0"
},
"paths": {
"/api/audio/upload": {
"/api/api/audio/upload": {
"post": {
"tags": [
"audio"
],
"summary": "Upload Audio",
"operationId": "upload_audio_api_audio_upload_post",
"operationId": "upload_audio_api_api_audio_upload_post",
"requestBody": {
"content": {
"multipart/form-data": {
"schema": {
"$ref": "#/components/schemas/Body_upload_audio_api_audio_upload_post"
"$ref": "#/components/schemas/Body_upload_audio_api_api_audio_upload_post"
}
}
},
Expand Down Expand Up @@ -1872,8 +1872,8 @@
}
}
},
"Body_upload_audio_api_audio_upload_post": {
"title": "Body_upload_audio_api_audio_upload_post",
"Body_upload_audio_api_api_audio_upload_post": {
"title": "Body_upload_audio_api_api_audio_upload_post",
"required": [
"file"
],
Expand Down
Loading