feat: persist upload creation metadata

Add the first upload API endpoint from PLAN.md. POST /api/uploads now
validates the requested file name, generates a server-owned upload id, creates
the staging and complete directory layout, and writes durable meta.json before
returning chunk scheduling details to the browser.

Keep filesystem layout knowledge in storage.rs so later chunk upload and
completion work can reuse the same boundary. API handlers translate storage
errors into JSON HTTP responses without leaking layout details into the router.

Document the new modules and UPL_DATA_DIR configuration, and extend TESTS.md
with the automated creation coverage.

Test Plan:
- just check

Refs: PLAN.md milestone 2
This commit is contained in:
2026-05-30 16:57:48 +02:00
parent a3f369f437
commit 24ecdbd251
9 changed files with 458 additions and 4 deletions
+6 -1
View File
@@ -16,7 +16,10 @@ assembly are tracked in `PLAN.md` and will be added in later coherent slices.
upl
Rust server
src/main.rs binary entrypoint and listener setup
src/app.rs Axum router, health endpoint, static file service
src/app.rs Axum router, shared state, static file service
src/api.rs HTTP handlers and API error responses
src/model.rs JSON request, response, and metadata shapes
src/storage.rs local filesystem layout and durable metadata writes
src/lib.rs library surface used by integration tests
Browser UI
static/index.html upload tool markup
@@ -32,6 +35,8 @@ upl
- `UPL_BIND` sets the listen address. It defaults to `127.0.0.1:3000`.
- `UPL_STATIC_DIR` sets the static asset directory. It defaults to `static/`
inside this repository.
- `UPL_DATA_DIR` sets the upload data directory. It defaults to `data/` inside
this repository.
## Common Commands
+2
View File
@@ -10,6 +10,8 @@ Keep this file as the reusable verification checklist while implementing
- Current coverage:
- `GET /` serves the static browser page.
- `GET /healthz` reports `ok`.
- `POST /api/uploads` creates `meta.json` and chunk directories.
- `POST /api/uploads` rejects an empty file name.
## Manual
+65
View File
@@ -0,0 +1,65 @@
use axum::{
Json,
extract::State,
http::StatusCode,
response::{IntoResponse, Response},
};
use serde::Serialize;
use crate::{
app::AppState,
model::{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()))
}
#[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_invalid_input() {
StatusCode::BAD_REQUEST
} else {
StatusCode::INTERNAL_SERVER_ERROR
};
Self {
status,
message: error.to_string(),
}
}
}
#[derive(Serialize)]
struct ErrorResponse {
error: String,
}
+31 -2
View File
@@ -5,17 +5,29 @@ use std::{
path::{Path, PathBuf},
};
use axum::{Router, routing::get};
use axum::{
Router,
routing::{get, post},
};
use tower_http::services::{ServeDir, ServeFile};
use crate::{api, storage::Storage};
const DEFAULT_BIND_ADDR: &str = "127.0.0.1:3000";
const STATIC_DIR_ENV: &str = "UPL_STATIC_DIR";
const DATA_DIR_ENV: &str = "UPL_DATA_DIR";
const BIND_ENV: &str = "UPL_BIND";
#[derive(Clone, Debug)]
pub struct AppConfig {
pub bind_addr: SocketAddr,
pub static_dir: PathBuf,
pub data_dir: PathBuf,
}
#[derive(Clone, Debug)]
pub struct AppState {
pub storage: Storage,
}
impl AppConfig {
@@ -29,26 +41,39 @@ impl AppConfig {
.unwrap_or_else(|_| DEFAULT_BIND_ADDR.to_owned())
.parse()?;
let static_dir = env::var_os(STATIC_DIR_ENV).map_or_else(default_static_dir, PathBuf::from);
let data_dir = env::var_os(DATA_DIR_ENV).map_or_else(default_data_dir, PathBuf::from);
Ok(Self {
bind_addr,
static_dir,
data_dir,
})
}
#[must_use]
pub fn new(bind_addr: SocketAddr, static_dir: impl Into<PathBuf>) -> Self {
pub fn new(
bind_addr: SocketAddr,
static_dir: impl Into<PathBuf>,
data_dir: impl Into<PathBuf>,
) -> Self {
Self {
bind_addr,
static_dir: static_dir.into(),
data_dir: data_dir.into(),
}
}
}
pub fn build_router(config: &AppConfig) -> Router {
let state = AppState {
storage: Storage::new(&config.data_dir),
};
Router::new()
.route("/healthz", get(healthz))
.route("/api/uploads", post(api::create_upload))
.fallback_service(static_service(&config.static_dir))
.with_state(state)
}
async fn healthz() -> &'static str {
@@ -62,3 +87,7 @@ fn static_service(static_dir: &Path) -> ServeDir<ServeFile> {
fn default_static_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("static")
}
fn default_data_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("data")
}
+3
View File
@@ -1 +1,4 @@
pub mod api;
pub mod app;
pub mod model;
pub mod storage;
+42
View File
@@ -0,0 +1,42 @@
use serde::{Deserialize, Serialize};
pub const CHUNK_SIZE: u64 = 16 * 1024 * 1024;
#[derive(Debug, Deserialize)]
pub struct CreateUploadRequest {
pub name: String,
pub size: u64,
pub last_modified: i64,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct CreateUploadResponse {
pub upload_id: String,
pub chunk_size: u64,
pub total_chunks: u64,
pub completed_chunks: Vec<u64>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct UploadMeta {
pub id: String,
pub original_name: String,
pub safe_name: String,
pub size: u64,
pub last_modified: i64,
pub chunk_size: u64,
pub total_chunks: u64,
pub created_at: String,
}
impl UploadMeta {
#[must_use]
pub fn create_response(&self) -> CreateUploadResponse {
CreateUploadResponse {
upload_id: self.id.clone(),
chunk_size: self.chunk_size,
total_chunks: self.total_chunks,
completed_chunks: Vec::new(),
}
}
}
+208
View File
@@ -0,0 +1,208 @@
use std::{
error::Error,
fmt::{self, Display},
path::{Path, PathBuf},
};
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
use tokio::fs;
use uuid::Uuid;
use crate::model::{CHUNK_SIZE, CreateUploadRequest, UploadMeta};
#[derive(Clone, Debug)]
pub struct Storage {
data_dir: PathBuf,
}
impl Storage {
#[must_use]
pub fn new(data_dir: impl Into<PathBuf>) -> Self {
Self {
data_dir: data_dir.into(),
}
}
/// Creates a durable upload metadata record under `data/staging`.
///
/// # Errors
///
/// Returns an error when directories cannot be created, metadata cannot be
/// serialized, or the metadata file cannot be written atomically.
pub async fn create_upload(
&self,
request: CreateUploadRequest,
) -> Result<UploadMeta, StorageError> {
let original_name = request.name.trim();
if original_name.is_empty() {
return Err(StorageError::InvalidInput("file name cannot be empty"));
}
self.ensure_layout().await?;
let safe_name = safe_file_name(original_name);
let created_at = OffsetDateTime::now_utc().format(&Rfc3339)?;
for _ in 0..8 {
let id = Uuid::new_v4().simple().to_string();
let upload_dir = self.upload_dir(&id);
if fs::try_exists(&upload_dir).await? {
continue;
}
fs::create_dir_all(upload_dir.join("chunks")).await?;
let meta = UploadMeta {
id,
original_name: original_name.to_owned(),
safe_name,
size: request.size,
last_modified: request.last_modified,
chunk_size: CHUNK_SIZE,
total_chunks: total_chunks(request.size, CHUNK_SIZE),
created_at,
};
self.write_meta(&meta).await?;
return Ok(meta);
}
Err(StorageError::IdCollision)
}
#[must_use]
pub fn data_dir(&self) -> &Path {
&self.data_dir
}
fn staging_dir(&self) -> PathBuf {
self.data_dir.join("staging")
}
fn complete_dir(&self) -> PathBuf {
self.data_dir.join("complete")
}
fn upload_dir(&self, upload_id: &str) -> PathBuf {
self.staging_dir().join(upload_id)
}
async fn ensure_layout(&self) -> Result<(), StorageError> {
fs::create_dir_all(self.staging_dir()).await?;
fs::create_dir_all(self.complete_dir()).await?;
Ok(())
}
async fn write_meta(&self, meta: &UploadMeta) -> Result<(), StorageError> {
let meta_path = self.upload_dir(&meta.id).join("meta.json");
let tmp_path = meta_path.with_extension("json.tmp");
let json = serde_json::to_vec_pretty(meta)?;
fs::write(&tmp_path, json).await?;
fs::rename(&tmp_path, &meta_path).await?;
Ok(())
}
}
#[derive(Debug)]
pub enum StorageError {
Format(time::error::Format),
IdCollision,
InvalidInput(&'static str),
Io(std::io::Error),
Json(serde_json::Error),
}
impl StorageError {
#[must_use]
pub fn is_invalid_input(&self) -> bool {
matches!(self, Self::InvalidInput(_))
}
}
impl Display for StorageError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Format(error) => write!(formatter, "failed to format timestamp: {error}"),
Self::IdCollision => formatter.write_str("could not allocate a unique upload id"),
Self::InvalidInput(message) => formatter.write_str(message),
Self::Io(error) => write!(formatter, "storage I/O error: {error}"),
Self::Json(error) => write!(formatter, "metadata JSON error: {error}"),
}
}
}
impl Error for StorageError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
match self {
Self::Format(error) => Some(error),
Self::Io(error) => Some(error),
Self::Json(error) => Some(error),
Self::IdCollision | Self::InvalidInput(_) => None,
}
}
}
impl From<std::io::Error> for StorageError {
fn from(error: std::io::Error) -> Self {
Self::Io(error)
}
}
impl From<serde_json::Error> for StorageError {
fn from(error: serde_json::Error) -> Self {
Self::Json(error)
}
}
impl From<time::error::Format> for StorageError {
fn from(error: time::error::Format) -> Self {
Self::Format(error)
}
}
fn safe_file_name(name: &str) -> String {
let safe: String = name
.chars()
.map(|character| match character {
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
character if character.is_control() => '_',
character => character,
})
.collect();
let trimmed = safe.trim_matches(['.', ' ', '_']);
if trimmed.is_empty() {
"upload".to_owned()
} else {
trimmed.to_owned()
}
}
fn total_chunks(size: u64, chunk_size: u64) -> u64 {
if size == 0 {
0
} else {
size.div_ceil(chunk_size)
}
}
#[cfg(test)]
mod tests {
use super::{safe_file_name, total_chunks};
#[test]
fn computes_total_chunks() {
assert_eq!(total_chunks(0, 16), 0);
assert_eq!(total_chunks(1, 16), 1);
assert_eq!(total_chunks(16, 16), 1);
assert_eq!(total_chunks(17, 16), 2);
}
#[test]
fn sanitizes_file_names() {
assert_eq!(safe_file_name("../movie:mkv"), "movie_mkv");
assert_eq!(safe_file_name(" "), "upload");
}
}
+5 -1
View File
@@ -1,4 +1,7 @@
use std::net::{Ipv4Addr, SocketAddr};
use std::{
net::{Ipv4Addr, SocketAddr},
path::Path,
};
use axum::{body::Body, http::Request};
use http_body_util::BodyExt;
@@ -44,5 +47,6 @@ fn test_app() -> axum::Router {
build_router(&AppConfig::new(
SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
concat!(env!("CARGO_MANIFEST_DIR"), "/static"),
Path::new(env!("CARGO_MANIFEST_DIR")).join("target/test-data/static-server"),
))
}
+96
View File
@@ -0,0 +1,96 @@
use std::{
net::{Ipv4Addr, SocketAddr},
path::Path,
};
use axum::{
body::Body,
http::{Request, StatusCode, header},
};
use http_body_util::BodyExt;
use serde_json::json;
use tempfile::TempDir;
use tower::ServiceExt;
use upl::{
app::{AppConfig, build_router},
model::{CHUNK_SIZE, CreateUploadResponse, UploadMeta},
};
#[tokio::test]
async fn creates_upload_metadata_on_disk() -> Result<(), Box<dyn std::error::Error>> {
let temp_dir = TempDir::new()?;
let app = test_app(temp_dir.path());
let response = app
.oneshot(json_request(
"/api/uploads",
&json!({
"name": "movie:mkv",
"size": CHUNK_SIZE + 1,
"last_modified": 1_760_000_000_000_i64
}),
)?)
.await?;
assert_eq!(response.status(), StatusCode::OK);
let response_body = response.into_body().collect().await?.to_bytes();
let response: CreateUploadResponse = serde_json::from_slice(&response_body)?;
assert_eq!(response.chunk_size, CHUNK_SIZE);
assert_eq!(response.total_chunks, 2);
assert!(response.completed_chunks.is_empty());
let upload_dir = temp_dir.path().join("staging").join(&response.upload_id);
let meta_path = upload_dir.join("meta.json");
assert!(upload_dir.join("chunks").is_dir());
assert!(temp_dir.path().join("complete").is_dir());
let meta: UploadMeta = serde_json::from_slice(&tokio::fs::read(meta_path).await?)?;
assert_eq!(meta.id, response.upload_id);
assert_eq!(meta.original_name, "movie:mkv");
assert_eq!(meta.safe_name, "movie_mkv");
assert_eq!(meta.total_chunks, 2);
Ok(())
}
#[tokio::test]
async fn rejects_empty_upload_name() -> Result<(), Box<dyn std::error::Error>> {
let temp_dir = TempDir::new()?;
let app = test_app(temp_dir.path());
let response = app
.oneshot(json_request(
"/api/uploads",
&json!({
"name": " ",
"size": 10,
"last_modified": 1_760_000_000_000_i64
}),
)?)
.await?;
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
Ok(())
}
fn test_app(data_dir: &Path) -> axum::Router {
build_router(&AppConfig::new(
SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
concat!(env!("CARGO_MANIFEST_DIR"), "/static"),
data_dir,
))
}
fn json_request(
uri: &str,
body: &serde_json::Value,
) -> Result<Request<Body>, Box<dyn std::error::Error>> {
Ok(Request::builder()
.method("POST")
.uri(uri)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(serde_json::to_vec(&body)?))?)
}