use axum::extract::{Multipart, State};
use axum::response::{IntoResponse, Response};
use axum::Extension;
use sbol_db_app::SubmitRequest;
use sbol_db_core::{DomainError, SerializationFormat};
use sbol_db_storage::ImportOverwrite;
use super::CurrentUser;
use crate::{ApiError, AppState};
#[derive(Default)]
struct SubmitForm {
id: Option<String>,
version: Option<String>,
name: Option<String>,
description: Option<String>,
citations: Option<String>,
overwrite_merge: Option<String>,
format: Option<String>,
file: Option<String>,
file_name: Option<String>,
root_collections: Option<String>,
}
pub async fn submit(
State(state): State<AppState>,
Extension(CurrentUser(user)): Extension<CurrentUser>,
multipart: Multipart,
) -> Result<Response, ApiError> {
let Some(user) = user else {
return Err(ApiError::Forbidden(
"authentication is required to submit".to_owned(),
));
};
let form = parse_form(multipart).await?;
let root_collections = form.root_collections.filter(|s| !s.is_empty());
let (id, version) = match &root_collections {
Some(uri) => collection_id_version(uri)
.ok_or_else(|| ApiError::BadRequest(format!("unrecognized collection URI: {uri}")))?,
None => (required(form.id, "id")?, required(form.version, "version")?),
};
let (body, format) = match form.file.filter(|f| !f.is_empty()) {
Some(file) => (
file,
resolve_format(form.format.as_deref(), form.file_name.as_deref())?,
),
None => (String::new(), SerializationFormat::Turtle),
};
let overwrite = if root_collections.is_some() {
ImportOverwrite::Merge
} else {
resolve_overwrite_merge(form.overwrite_merge.as_deref())?
};
let citations = parse_citations(form.citations.as_deref());
let request = SubmitRequest {
owner: user.username.clone(),
id,
version,
name: form.name.filter(|s| !s.is_empty()),
description: form.description.filter(|s| !s.is_empty()),
creator_name: Some(user.name.clone()),
citations,
body,
format,
overwrite,
};
state
.app
.submission_service()
.submit(request)
.await
.map_err(submit_error)?;
Ok((
axum::http::StatusCode::OK,
[(axum::http::header::CONTENT_TYPE, "text/plain")],
"Successfully uploaded",
)
.into_response())
}
fn submit_error(err: DomainError) -> ApiError {
match err {
DomainError::Validation(message) => ApiError::BadRequest(message),
other => ApiError::Domain(other),
}
}
async fn parse_form(mut multipart: Multipart) -> Result<SubmitForm, ApiError> {
let mut form = SubmitForm::default();
while let Some(field) = multipart
.next_field()
.await
.map_err(|e| ApiError::BadRequest(format!("malformed multipart body: {e}")))?
{
let name = field.name().map(str::to_owned);
match name.as_deref() {
Some("file") => {
form.file_name = field.file_name().map(str::to_owned);
form.file = Some(field_text(field).await?);
}
Some("id") => form.id = Some(field_text(field).await?),
Some("version") => form.version = Some(field_text(field).await?),
Some("name") => form.name = Some(field_text(field).await?),
Some("description") => form.description = Some(field_text(field).await?),
Some("citations") => form.citations = Some(field_text(field).await?),
Some("overwrite_merge") => form.overwrite_merge = Some(field_text(field).await?),
Some("rootCollections") | Some("collections") => {
form.root_collections = Some(field_text(field).await?)
}
Some("format") => form.format = Some(field_text(field).await?),
_ => {
let _ = field.bytes().await;
}
}
}
Ok(form)
}
async fn field_text(field: axum::extract::multipart::Field<'_>) -> Result<String, ApiError> {
field
.text()
.await
.map_err(|e| ApiError::BadRequest(format!("invalid multipart field: {e}")))
}
fn required(value: Option<String>, field: &str) -> Result<String, ApiError> {
value
.filter(|s| !s.is_empty())
.ok_or_else(|| ApiError::BadRequest(format!("{field} is required")))
}
fn parse_citations(raw: Option<&str>) -> Vec<String> {
raw.map(|s| {
s.split(',')
.map(str::trim)
.filter(|c| !c.is_empty())
.map(str::to_owned)
.collect()
})
.unwrap_or_default()
}
fn collection_id_version(uri: &str) -> Option<(String, String)> {
let mut segments = uri.trim_end_matches('/').rsplit('/');
let version = segments.next()?.to_owned();
let id = segments.next()?.strip_suffix("_collection")?.to_owned();
if id.is_empty() || version.is_empty() {
return None;
}
Some((id, version))
}
fn resolve_format(
format: Option<&str>,
file_name: Option<&str>,
) -> Result<SerializationFormat, ApiError> {
let hint = format
.filter(|s| !s.is_empty())
.map(str::to_owned)
.or_else(|| file_name.and_then(extension).map(str::to_owned));
let resolved = match hint.as_deref() {
None => SerializationFormat::RdfXml,
Some(hint) => parse_rdf_format(hint).ok_or_else(|| {
ApiError::BadRequest(format!("unsupported submission format: {hint}"))
})?,
};
Ok(resolved)
}
fn extension(file_name: &str) -> Option<&str> {
file_name.rsplit_once('.').map(|(_, ext)| ext)
}
fn parse_rdf_format(hint: &str) -> Option<SerializationFormat> {
match hint.to_ascii_lowercase().as_str() {
"turtle" | "ttl" => Some(SerializationFormat::Turtle),
"jsonld" => Some(SerializationFormat::JsonLd),
"rdfxml" | "rdf" | "xml" => Some(SerializationFormat::RdfXml),
"ntriples" | "nt" => Some(SerializationFormat::NTriples),
_ => None,
}
}
fn resolve_overwrite_merge(code: Option<&str>) -> Result<ImportOverwrite, ApiError> {
let code = code.map(str::trim).filter(|s| !s.is_empty()).unwrap_or("0");
match code {
"0" => Ok(ImportOverwrite::Fail),
"1" => Ok(ImportOverwrite::Replace),
"2" | "3" => Ok(ImportOverwrite::Merge),
other => Err(ApiError::BadRequest(format!(
"invalid overwrite_merge: {other}"
))),
}
}