feat: Add file upload endpoint
This commit introduces a new API endpoint `/api/upload` for handling audio file uploads. It supports multipart form data, extracts `case_id`, `recorded_at`, and `audio` fields. The audio data is saved to a filesystem path determined by the user and case ID, within either the `open/` or `done/` directories. New features include: - Integration with `axum`'s `Multipart` extractor for parsing form data. - Validation of `case_id` as a UUID. - Creation of case directories if they do not exist. - Handling of late uploads to already closed cases by placing files in the `done/` directory and removing any `.remove` marker. - Logging of received recordings. - Definition of `AckResponse` and `AckStatus` models for API responses. - Addition of comprehensive unit tests for various upload scenarios, including new case creation, invalid inputs, authentication, and late uploads.
This commit is contained in:
Generated
+24
@@ -92,6 +92,7 @@ dependencies = [
|
||||
"matchit",
|
||||
"memchr",
|
||||
"mime",
|
||||
"multer",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"serde_core",
|
||||
@@ -933,6 +934,23 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "multer"
|
||||
version = "3.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"encoding_rs",
|
||||
"futures-util",
|
||||
"http",
|
||||
"httparse",
|
||||
"memchr",
|
||||
"mime",
|
||||
"spin",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "native-tls"
|
||||
version = "0.2.18"
|
||||
@@ -1469,6 +1487,12 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spin"
|
||||
version = "0.9.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
|
||||
|
||||
[[package]]
|
||||
name = "stable_deref_trait"
|
||||
version = "1.2.1"
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
axum = "0.8"
|
||||
axum = { version = "0.8", features = ["multipart"] }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
reqwest = { version = "0.12", features = ["json", "multipart"] }
|
||||
askama = "0.12"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
pub mod auth;
|
||||
pub mod config;
|
||||
pub mod error;
|
||||
pub mod models;
|
||||
pub mod routes;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct AckResponse {
|
||||
pub case_id: String,
|
||||
pub recorded_at: String,
|
||||
pub status: AckStatus,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum AckStatus {
|
||||
Received,
|
||||
Gone,
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
mod debug;
|
||||
mod health;
|
||||
mod upload;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::routing::get;
|
||||
use axum::routing::{get, post};
|
||||
use axum::Router;
|
||||
|
||||
use crate::config::Config;
|
||||
@@ -12,4 +13,5 @@ pub fn api_router() -> Router<Arc<Config>> {
|
||||
Router::new()
|
||||
.route("/api/health", get(health::handle_health))
|
||||
.route("/api/debug/whoami", get(debug::handle_whoami))
|
||||
.route("/api/upload", post(upload::handle_upload))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use axum::extract::Multipart;
|
||||
use axum::Json;
|
||||
use tracing::info;
|
||||
|
||||
use crate::auth::AuthenticatedUser;
|
||||
use crate::error::AppError;
|
||||
use crate::models::{AckResponse, AckStatus};
|
||||
|
||||
pub async fn handle_upload(
|
||||
user: AuthenticatedUser,
|
||||
mut multipart: Multipart,
|
||||
) -> Result<Json<AckResponse>, AppError> {
|
||||
let mut case_id: Option<String> = None;
|
||||
let mut recorded_at: Option<String> = None;
|
||||
let mut audio: Option<Vec<u8>> = None;
|
||||
|
||||
// Consume multipart fields in whatever order they arrive.
|
||||
while let Some(field) = multipart
|
||||
.next_field()
|
||||
.await
|
||||
.map_err(|e| AppError::BadRequest(format!("Invalid multipart data: {e}")))?
|
||||
{
|
||||
match field.name() {
|
||||
Some("case_id") => {
|
||||
case_id = Some(
|
||||
field
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| AppError::BadRequest(format!("Invalid case_id: {e}")))?,
|
||||
);
|
||||
}
|
||||
Some("recorded_at") => {
|
||||
recorded_at = Some(
|
||||
field
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| AppError::BadRequest(format!("Invalid recorded_at: {e}")))?,
|
||||
);
|
||||
}
|
||||
Some("audio") => {
|
||||
audio = Some(
|
||||
field
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| AppError::BadRequest(format!("Invalid audio data: {e}")))?
|
||||
.to_vec(),
|
||||
);
|
||||
}
|
||||
_ => {} // Ignore unknown fields
|
||||
}
|
||||
}
|
||||
|
||||
let case_id = case_id.ok_or_else(|| AppError::BadRequest("Missing field: case_id".into()))?;
|
||||
let recorded_at =
|
||||
recorded_at.ok_or_else(|| AppError::BadRequest("Missing field: recorded_at".into()))?;
|
||||
let audio = audio.ok_or_else(|| AppError::BadRequest("Missing field: audio".into()))?;
|
||||
|
||||
if audio.is_empty() {
|
||||
return Err(AppError::BadRequest("Audio data is empty".into()));
|
||||
}
|
||||
|
||||
// Validate case_id as UUID (prevents path traversal — UUIDs only contain hex + hyphens).
|
||||
uuid::Uuid::parse_str(&case_id)
|
||||
.map_err(|_| AppError::BadRequest(format!("Invalid case_id (not a UUID): {case_id}")))?;
|
||||
|
||||
// Find or create the case directory.
|
||||
let case_dir = resolve_case_dir(&user.data_dir, &case_id).await?;
|
||||
|
||||
// Build a filesystem-safe filename from the timestamp (colons → hyphens).
|
||||
let safe_timestamp = recorded_at.replace(':', "-");
|
||||
let file_path = case_dir.join(format!("{safe_timestamp}.m4a"));
|
||||
|
||||
tokio::fs::write(&file_path, &audio).await?;
|
||||
|
||||
info!(
|
||||
user = %user.slug,
|
||||
case_id = %case_id,
|
||||
bytes = audio.len(),
|
||||
"Recording received"
|
||||
);
|
||||
|
||||
Ok(Json(AckResponse {
|
||||
case_id,
|
||||
recorded_at,
|
||||
status: AckStatus::Received,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Determine where to store the audio file for this case.
|
||||
/// Creates the directory if it's a new case.
|
||||
async fn resolve_case_dir(data_dir: &Path, case_id: &str) -> Result<PathBuf, AppError> {
|
||||
let open_dir = data_dir.join("open").join(case_id);
|
||||
if open_dir.exists() {
|
||||
return Ok(open_dir);
|
||||
}
|
||||
|
||||
let done_dir = data_dir.join("done").join(case_id);
|
||||
if done_dir.exists() {
|
||||
// Late upload for a closed case — remove .remove marker if present.
|
||||
let remove_marker = done_dir.join(".remove");
|
||||
if remove_marker.exists() {
|
||||
tokio::fs::remove_file(&remove_marker).await?;
|
||||
info!(case_id = %case_id, "Removed .remove marker due to late upload");
|
||||
}
|
||||
return Ok(done_dir);
|
||||
}
|
||||
|
||||
// New case — create directory under open/.
|
||||
tokio::fs::create_dir_all(&open_dir).await?;
|
||||
info!(case_id = %case_id, "New case created");
|
||||
|
||||
Ok(open_dir)
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use tower::util::ServiceExt;
|
||||
|
||||
use doctate_server::config::{Config, User};
|
||||
|
||||
fn test_config() -> Arc<Config> {
|
||||
let data_path = std::env::temp_dir().join(format!(
|
||||
"doctate-test-{}-{}",
|
||||
std::process::id(),
|
||||
uuid::Uuid::new_v4()
|
||||
));
|
||||
Arc::new(Config {
|
||||
server_port: 3000,
|
||||
data_path,
|
||||
log_level: "info".into(),
|
||||
log_path: "/tmp/doctate-test/logs".into(),
|
||||
log_max_days: 90,
|
||||
users: vec![User {
|
||||
slug: "dr_test".into(),
|
||||
api_key: "test-key-123".into(),
|
||||
web_password: "unused".into(),
|
||||
role: "doctor".into(),
|
||||
}],
|
||||
api_keys: HashMap::from([("test-key-123".into(), "dr_test".into())]),
|
||||
retention_audio_days: 30,
|
||||
retention_transcript_days: 30,
|
||||
retention_document_days: 0,
|
||||
whisper_url: "http://localhost:10300".into(),
|
||||
whisper_timeout_seconds: 120,
|
||||
ollama_url: "http://localhost:11434".into(),
|
||||
ollama_model: "gemma3:4b".into(),
|
||||
ollama_keep_alive: 0,
|
||||
llm_url: String::new(),
|
||||
llm_api_key: String::new(),
|
||||
llm_model: String::new(),
|
||||
llm_temperature: 0.0,
|
||||
session_timeout_hours: 8,
|
||||
})
|
||||
}
|
||||
|
||||
/// Build a multipart body with the given fields.
|
||||
fn multipart_body(
|
||||
case_id: &str,
|
||||
recorded_at: &str,
|
||||
audio: &[u8],
|
||||
) -> (String, Vec<u8>) {
|
||||
let boundary = "----testboundary";
|
||||
let mut body = Vec::new();
|
||||
|
||||
// case_id field
|
||||
body.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
|
||||
body.extend_from_slice(b"Content-Disposition: form-data; name=\"case_id\"\r\n\r\n");
|
||||
body.extend_from_slice(case_id.as_bytes());
|
||||
body.extend_from_slice(b"\r\n");
|
||||
|
||||
// recorded_at field
|
||||
body.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
|
||||
body.extend_from_slice(b"Content-Disposition: form-data; name=\"recorded_at\"\r\n\r\n");
|
||||
body.extend_from_slice(recorded_at.as_bytes());
|
||||
body.extend_from_slice(b"\r\n");
|
||||
|
||||
// audio field
|
||||
body.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
|
||||
body.extend_from_slice(
|
||||
b"Content-Disposition: form-data; name=\"audio\"; filename=\"test.m4a\"\r\n",
|
||||
);
|
||||
body.extend_from_slice(b"Content-Type: audio/mp4\r\n\r\n");
|
||||
body.extend_from_slice(audio);
|
||||
body.extend_from_slice(b"\r\n");
|
||||
|
||||
// End boundary
|
||||
body.extend_from_slice(format!("--{boundary}--\r\n").as_bytes());
|
||||
|
||||
(boundary.to_owned(), body)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn upload_creates_new_case() {
|
||||
let config = test_config();
|
||||
let data_path = config.data_path.clone();
|
||||
let app = doctate_server::create_router(config);
|
||||
|
||||
let case_id = "550e8400-e29b-41d4-a716-446655440000";
|
||||
let (boundary, body) = multipart_body(case_id, "2026-04-13T10:30:00Z", b"fake audio data");
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/upload")
|
||||
.header("X-API-Key", "test-key-123")
|
||||
.header(
|
||||
"Content-Type",
|
||||
format!("multipart/form-data; boundary={boundary}"),
|
||||
)
|
||||
.body(Body::from(body))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||
|
||||
assert_eq!(json["case_id"], case_id);
|
||||
assert_eq!(json["status"], "received");
|
||||
|
||||
// Verify file on disk
|
||||
let file = data_path
|
||||
.join("dr_test/open")
|
||||
.join(case_id)
|
||||
.join("2026-04-13T10-30-00Z.m4a");
|
||||
assert!(file.exists(), "Audio file should exist at {file:?}");
|
||||
|
||||
// Cleanup
|
||||
let _ = std::fs::remove_dir_all(&data_path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn upload_invalid_case_id_returns_400() {
|
||||
let config = test_config();
|
||||
let app = doctate_server::create_router(config);
|
||||
|
||||
let (boundary, body) = multipart_body("../etc/passwd", "2026-04-13T10:30:00Z", b"audio");
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/upload")
|
||||
.header("X-API-Key", "test-key-123")
|
||||
.header(
|
||||
"Content-Type",
|
||||
format!("multipart/form-data; boundary={boundary}"),
|
||||
)
|
||||
.body(Body::from(body))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn upload_without_auth_returns_401() {
|
||||
let config = test_config();
|
||||
let app = doctate_server::create_router(config);
|
||||
|
||||
let (boundary, body) = multipart_body(
|
||||
"550e8400-e29b-41d4-a716-446655440000",
|
||||
"2026-04-13T10:30:00Z",
|
||||
b"audio",
|
||||
);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/upload")
|
||||
.header(
|
||||
"Content-Type",
|
||||
format!("multipart/form-data; boundary={boundary}"),
|
||||
)
|
||||
.body(Body::from(body))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn upload_empty_audio_returns_400() {
|
||||
let config = test_config();
|
||||
let app = doctate_server::create_router(config);
|
||||
|
||||
let (boundary, body) = multipart_body(
|
||||
"550e8400-e29b-41d4-a716-446655440000",
|
||||
"2026-04-13T10:30:00Z",
|
||||
b"", // empty audio
|
||||
);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/upload")
|
||||
.header("X-API-Key", "test-key-123")
|
||||
.header(
|
||||
"Content-Type",
|
||||
format!("multipart/form-data; boundary={boundary}"),
|
||||
)
|
||||
.body(Body::from(body))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn upload_second_recording_same_case() {
|
||||
let config = test_config();
|
||||
let data_path = config.data_path.clone();
|
||||
let app = doctate_server::create_router(config);
|
||||
|
||||
let case_id = "660e8400-e29b-41d4-a716-446655440000";
|
||||
|
||||
// First upload
|
||||
let (boundary, body) = multipart_body(case_id, "2026-04-13T10:30:00Z", b"first recording");
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/upload")
|
||||
.header("X-API-Key", "test-key-123")
|
||||
.header(
|
||||
"Content-Type",
|
||||
format!("multipart/form-data; boundary={boundary}"),
|
||||
)
|
||||
.body(Body::from(body))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
// Second upload, same case, different timestamp
|
||||
let (boundary, body) = multipart_body(case_id, "2026-04-13T10:45:00Z", b"second recording");
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/upload")
|
||||
.header("X-API-Key", "test-key-123")
|
||||
.header(
|
||||
"Content-Type",
|
||||
format!("multipart/form-data; boundary={boundary}"),
|
||||
)
|
||||
.body(Body::from(body))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
// Both files should exist
|
||||
let case_dir = data_path.join("dr_test/open").join(case_id);
|
||||
assert!(case_dir.join("2026-04-13T10-30-00Z.m4a").exists());
|
||||
assert!(case_dir.join("2026-04-13T10-45-00Z.m4a").exists());
|
||||
|
||||
// Cleanup
|
||||
let _ = std::fs::remove_dir_all(&data_path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn upload_late_recording_to_done_case() {
|
||||
let config = test_config();
|
||||
let data_path = config.data_path.clone();
|
||||
let app = doctate_server::create_router(config);
|
||||
|
||||
let case_id = "770e8400-e29b-41d4-a716-446655440000";
|
||||
|
||||
// Pre-create a done/ case directory (simulating a closed case).
|
||||
let done_dir = data_path.join("dr_test/done").join(case_id);
|
||||
std::fs::create_dir_all(&done_dir).unwrap();
|
||||
|
||||
let (boundary, body) = multipart_body(case_id, "2026-04-13T11:00:00Z", b"late recording");
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/upload")
|
||||
.header("X-API-Key", "test-key-123")
|
||||
.header(
|
||||
"Content-Type",
|
||||
format!("multipart/form-data; boundary={boundary}"),
|
||||
)
|
||||
.body(Body::from(body))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
// File should be in done/, not open/
|
||||
assert!(done_dir.join("2026-04-13T11-00-00Z.m4a").exists());
|
||||
assert!(!data_path.join("dr_test/open").join(case_id).exists());
|
||||
|
||||
// Cleanup
|
||||
let _ = std::fs::remove_dir_all(&data_path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn upload_removes_remove_marker_on_late_upload() {
|
||||
let config = test_config();
|
||||
let data_path = config.data_path.clone();
|
||||
let app = doctate_server::create_router(config);
|
||||
|
||||
let case_id = "880e8400-e29b-41d4-a716-446655440000";
|
||||
|
||||
// Pre-create a done/ case with .remove marker.
|
||||
let done_dir = data_path.join("dr_test/done").join(case_id);
|
||||
std::fs::create_dir_all(&done_dir).unwrap();
|
||||
std::fs::write(done_dir.join(".remove"), b"").unwrap();
|
||||
assert!(done_dir.join(".remove").exists());
|
||||
|
||||
let (boundary, body) = multipart_body(case_id, "2026-04-13T12:00:00Z", b"surprise recording");
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/upload")
|
||||
.header("X-API-Key", "test-key-123")
|
||||
.header(
|
||||
"Content-Type",
|
||||
format!("multipart/form-data; boundary={boundary}"),
|
||||
)
|
||||
.body(Body::from(body))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
// .remove marker should be gone, audio should be there
|
||||
assert!(!done_dir.join(".remove").exists());
|
||||
assert!(done_dir.join("2026-04-13T12-00-00Z.m4a").exists());
|
||||
|
||||
// Cleanup
|
||||
let _ = std::fs::remove_dir_all(&data_path);
|
||||
}
|
||||
Reference in New Issue
Block a user