Files
upl/src/api.rs
T
ddidderr 5ca52b5780 feat: assemble completed uploads
Implement POST /api/uploads/{id}/complete. The storage layer now reloads upload
metadata, verifies that every expected chunk exists with the exact expected
length, concatenates chunks in order into a temporary final file, flushes it,
and renames it into data/complete only after assembly succeeds.

The endpoint preserves staging data after completion, rejects incomplete uploads
with a conflict response, and refuses to overwrite an existing completed file.
This keeps failed or duplicate completion attempts explicit rather than silently
clobbering local files.

Extend the model, router, documentation, and test checklist for completion
responses and add integration coverage for successful assembly, incomplete
uploads, staging preservation, and duplicate completion conflicts.

Test Plan:
- just check

Refs: PLAN.md milestone 8
2026-05-30 17:02:59 +02:00

113 lines
2.9 KiB
Rust

use axum::{
Json,
body::Bytes,
extract::Path,
extract::State,
http::StatusCode,
response::{IntoResponse, Response},
};
use serde::Serialize;
use crate::{
app::AppState,
model::{CompleteUploadResponse, CreateUploadRequest, CreateUploadResponse},
storage::StorageError,
};
/// Creates an upload record and persists its metadata before returning.
///
/// # Errors
///
/// Returns an API error when request validation fails or metadata cannot be
/// written to storage.
pub async fn create_upload(
State(state): State<AppState>,
Json(request): Json<CreateUploadRequest>,
) -> Result<Json<CreateUploadResponse>, ApiError> {
let meta = state.storage.create_upload(request).await?;
Ok(Json(meta.create_response()))
}
/// Returns server-authoritative upload progress.
///
/// # Errors
///
/// Returns an API error when the upload id is invalid, unknown, or cannot be
/// read from storage.
pub async fn get_upload(
State(state): State<AppState>,
Path(upload_id): Path<String>,
) -> Result<Json<crate::model::UploadProgressResponse>, ApiError> {
Ok(Json(state.storage.progress(&upload_id).await?))
}
/// Stores one raw binary chunk body.
///
/// # Errors
///
/// Returns an API error when the upload id is invalid or unknown, the chunk
/// index is out of range, the body length is wrong, or the write fails.
pub async fn put_chunk(
State(state): State<AppState>,
Path((upload_id, index)): Path<(String, u64)>,
body: Bytes,
) -> Result<StatusCode, ApiError> {
state.storage.store_chunk(&upload_id, index, &body).await?;
Ok(StatusCode::NO_CONTENT)
}
/// Assembles uploaded chunks into the final completed file.
///
/// # Errors
///
/// Returns an API error when the upload is unknown, incomplete, invalid, or
/// cannot be assembled on disk.
pub async fn complete_upload(
State(state): State<AppState>,
Path(upload_id): Path<String>,
) -> Result<Json<CompleteUploadResponse>, ApiError> {
Ok(Json(state.storage.complete_upload(&upload_id).await?))
}
#[derive(Debug)]
pub struct ApiError {
status: StatusCode,
message: String,
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
(
self.status,
Json(ErrorResponse {
error: self.message,
}),
)
.into_response()
}
}
impl From<StorageError> for ApiError {
fn from(error: StorageError) -> Self {
let status = if error.is_not_found() {
StatusCode::NOT_FOUND
} else if error.is_conflict() {
StatusCode::CONFLICT
} else if error.is_invalid_input() {
StatusCode::BAD_REQUEST
} else {
StatusCode::INTERNAL_SERVER_ERROR
};
Self {
status,
message: error.to_string(),
}
}
}
#[derive(Serialize)]
struct ErrorResponse {
error: String,
}