diff --git a/.github/workflows/api-tests.yml b/.github/workflows/api-tests.yml new file mode 100644 index 000000000..c90d0adba --- /dev/null +++ b/.github/workflows/api-tests.yml @@ -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 diff --git a/src/Components/API/app/main.py b/src/Components/API/app/main.py index a7efe9486..70748725f 100644 --- a/src/Components/API/app/main.py +++ b/src/Components/API/app/main.py @@ -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 @@ -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 + diff --git a/src/Components/API/app/routers/audio_upload_router.py b/src/Components/API/app/routers/audio_upload_router.py index 33a830052..650471a95 100644 --- a/src/Components/API/app/routers/audio_upload_router.py +++ b/src/Components/API/app/routers/audio_upload_router.py @@ -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} diff --git a/src/Components/API/app/routers/species_predictor.py b/src/Components/API/app/routers/species_predictor.py index 81e8373ce..6a76cd2d5 100644 --- a/src/Components/API/app/routers/species_predictor.py +++ b/src/Components/API/app/routers/species_predictor.py @@ -3,27 +3,61 @@ 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): @@ -31,25 +65,44 @@ async def predict( 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") @@ -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}") diff --git a/src/Components/API/app/routers/uploads/20250821041747_audio_test.m4a b/src/Components/API/app/routers/uploads/20250821041747_audio_test.m4a new file mode 100644 index 000000000..b992b52a5 Binary files /dev/null and b/src/Components/API/app/routers/uploads/20250821041747_audio_test.m4a differ diff --git a/src/Components/API/app/routers/uploads/20250821041811_audio_test.m4a b/src/Components/API/app/routers/uploads/20250821041811_audio_test.m4a new file mode 100644 index 000000000..b992b52a5 Binary files /dev/null and b/src/Components/API/app/routers/uploads/20250821041811_audio_test.m4a differ diff --git a/src/Components/API/backend/project-echo-openapi.json b/src/Components/API/backend/project-echo-openapi.json index aff2a5220..dfda29ce9 100644 --- a/src/Components/API/backend/project-echo-openapi.json +++ b/src/Components/API/backend/project-echo-openapi.json @@ -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" } } }, @@ -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" ], diff --git a/tests/test_upload_predict.py b/tests/test_upload_predict.py new file mode 100644 index 000000000..a87e93b32 --- /dev/null +++ b/tests/test_upload_predict.py @@ -0,0 +1,37 @@ +import os +import pytest +import asyncio +from httpx import AsyncClient + +# These tests assume the API is running locally at 127.0.0.1:9000 +BASE = os.getenv("API_BASE", "http://127.0.0.1:9000") +SAMPLE = os.getenv("AUDIO_SAMPLE", "/Users/tenniele/Documents/Project A/Project-Echo/Audio_testAPI.mp3") + +pytestmark = pytest.mark.asyncio + +async def _skip_if_no_sample(): + if not os.path.exists(SAMPLE): + pytest.skip(f"Sample file not found: {SAMPLE}") + +async def test_upload_ok(): + await _skip_if_no_sample() + async with AsyncClient(base_url=BASE) as ac: + with open(SAMPLE, "rb") as f: + files = {"file": (os.path.basename(SAMPLE), f, "audio/mpeg")} + data = {"user_id": "ci-user"} + resp = await ac.post("/api/audio/upload", files=files, data=data) + assert resp.status_code == 200, resp.text + payload = resp.json() + assert "upload_id" in payload + assert payload["message"] == "Upload successful" + +async def test_predict_ok(): + await _skip_if_no_sample() + async with AsyncClient(base_url=BASE) as ac: + with open(SAMPLE, "rb") as f: + files = {"audio": (os.path.basename(SAMPLE), f, "audio/mpeg")} + data = {"user_id": "ci-user"} + resp = await ac.post("/predict", files=files, data=data) + assert resp.status_code == 200, resp.text + payload = resp.json() + assert "species" in payload and "confidence" in payload