use axum::body::Body;
use axum::extract::{Path, Query, State};
use axum::http::header::{CONTENT_DISPOSITION, CONTENT_TYPE};
use axum::response::Response;
use axum::Extension;
use sbol_db_app::Downloader;
use sbol_db_core::{DomainError, SerializationFormat, Triple, User};
use sbol_db_sparql::GraphScope;
use serde::Deserialize;
use super::routes::{
public_pi_uri, public_uri, run_scoped_value, scope_for, user_pi_uri, user_uri, PublicObject,
PublicObjectPi, UserObject, UserObjectPi,
};
use super::CurrentUser;
use crate::serialize::{
serialize_closure, serialize_gff3, serialize_omex, serialize_summary, OmexAttachment,
OmexAttachmentSource, Serialized,
};
use crate::{ApiError, AppState};
#[derive(Clone, Copy)]
pub(super) enum Format {
Sbol,
SbolNonRecursive,
GenBank,
Fasta,
Gff3,
Omex,
Summary,
}
impl Format {
fn extension(self) -> &'static str {
match self {
Format::Sbol | Format::SbolNonRecursive => "xml",
Format::GenBank => "gb",
Format::Fasta => "fasta",
Format::Gff3 => "gff",
Format::Omex => "omex",
Format::Summary => "json",
}
}
}
#[derive(Debug, Default, Deserialize)]
pub struct DownloadParams {
pub version: Option<String>,
}
fn wants_sbol2(version: Option<&str>) -> Result<bool, ApiError> {
match version.unwrap_or("sbol2") {
"sbol3" | "3" => Ok(false),
"sbol2" | "2" => Ok(true),
other => Err(ApiError::BadRequest(format!("unknown version: {other}"))),
}
}
macro_rules! download_routes {
($public:ident, $user:ident, $format:expr) => {
pub async fn $public(
State(state): State<AppState>,
Extension(CurrentUser(user)): Extension<CurrentUser>,
Path(object): Path<PublicObject>,
Query(params): Query<DownloadParams>,
) -> Result<Response, ApiError> {
let display_id = object.display_id.clone();
download(
state.clone(),
user,
public_uri(&state, &object),
display_id,
$format,
params.version,
)
.await
}
pub async fn $user(
State(state): State<AppState>,
Extension(CurrentUser(user)): Extension<CurrentUser>,
Path(object): Path<UserObject>,
Query(params): Query<DownloadParams>,
) -> Result<Response, ApiError> {
let display_id = object.display_id.clone();
download(
state.clone(),
user,
user_uri(&state, &object),
display_id,
$format,
params.version,
)
.await
}
};
}
download_routes!(public_sbol, user_sbol, Format::Sbol);
download_routes!(public_sbolnr, user_sbolnr, Format::SbolNonRecursive);
download_routes!(public_genbank, user_genbank, Format::GenBank);
download_routes!(public_fasta, user_fasta, Format::Fasta);
download_routes!(public_gff, user_gff, Format::Gff3);
download_routes!(public_omex, user_omex, Format::Omex);
download_routes!(public_summary, user_summary, Format::Summary);
pub(super) async fn latest_version_uri(
state: &AppState,
scope: GraphScope,
persistent_identity: &str,
) -> Result<Option<String>, ApiError> {
let query = format!(
"PREFIX sbol2: <http://sbols.org/v2#>\n\
SELECT ?version WHERE {{ ?s sbol2:persistentIdentity <{persistent_identity}> ; \
sbol2:version ?version }} ORDER BY DESC(?version) LIMIT 1"
);
let value = run_scoped_value(state, &query, scope).await?;
let version = value["results"]["bindings"]
.get(0)
.and_then(|b| b["version"]["value"].as_str())
.map(str::to_owned);
Ok(version.map(|v| format!("{persistent_identity}/{v}")))
}
macro_rules! object_routes {
($public:ident, $user:ident, $public_pi:ident, $user_pi:ident) => {
pub async fn $public(
State(state): State<AppState>,
Extension(CurrentUser(user)): Extension<CurrentUser>,
Path(object): Path<PublicObject>,
) -> Result<Response, ApiError> {
let display_id = object.display_id.clone();
download(
state.clone(),
user,
public_uri(&state, &object),
display_id,
Format::Sbol,
None,
)
.await
}
pub async fn $user(
State(state): State<AppState>,
Extension(CurrentUser(user)): Extension<CurrentUser>,
Path(object): Path<UserObject>,
) -> Result<Response, ApiError> {
let display_id = object.display_id.clone();
download(
state.clone(),
user,
user_uri(&state, &object),
display_id,
Format::Sbol,
None,
)
.await
}
pub async fn $public_pi(
State(state): State<AppState>,
Extension(CurrentUser(user)): Extension<CurrentUser>,
Path(object): Path<PublicObjectPi>,
) -> Result<Response, ApiError> {
let pi = public_pi_uri(&state, &object);
let scope = scope_for(&state, &user).await?;
let uri = latest_version_uri(&state, scope, &pi)
.await?
.ok_or_else(|| ApiError::NotFound(pi))?;
download(state, user, uri, object.display_id, Format::Sbol, None).await
}
pub async fn $user_pi(
State(state): State<AppState>,
Extension(CurrentUser(user)): Extension<CurrentUser>,
Path(object): Path<UserObjectPi>,
) -> Result<Response, ApiError> {
let pi = user_pi_uri(&state, &object);
let scope = scope_for(&state, &user).await?;
let uri = latest_version_uri(&state, scope, &pi)
.await?
.ok_or_else(|| ApiError::NotFound(pi))?;
download(state, user, uri, object.display_id, Format::Sbol, None).await
}
};
}
object_routes!(public_object, user_object, public_object_pi, user_object_pi);
pub async fn public_object_full(
State(state): State<AppState>,
Extension(CurrentUser(user)): Extension<CurrentUser>,
Path(object): Path<PublicObject>,
) -> Result<Response, ApiError> {
let display_id = object.display_id.clone();
download(
state.clone(),
user,
public_uri(&state, &object),
display_id,
Format::Sbol,
None,
)
.await
}
pub async fn user_object_full(
State(state): State<AppState>,
Extension(CurrentUser(user)): Extension<CurrentUser>,
Path(object): Path<UserObject>,
) -> Result<Response, ApiError> {
let display_id = object.display_id.clone();
download(
state.clone(),
user,
user_uri(&state, &object),
display_id,
Format::Sbol,
None,
)
.await
}
macro_rules! versionless_download_routes {
($public:ident, $user:ident, $format:expr) => {
pub async fn $public(
State(state): State<AppState>,
Extension(CurrentUser(user)): Extension<CurrentUser>,
Path(object): Path<PublicObjectPi>,
Query(params): Query<DownloadParams>,
) -> Result<Response, ApiError> {
let pi = public_pi_uri(&state, &object);
let scope = scope_for(&state, &user).await?;
let uri = latest_version_uri(&state, scope, &pi)
.await?
.ok_or_else(|| ApiError::NotFound(pi))?;
download(state, user, uri, object.display_id, $format, params.version).await
}
pub async fn $user(
State(state): State<AppState>,
Extension(CurrentUser(user)): Extension<CurrentUser>,
Path(object): Path<UserObjectPi>,
Query(params): Query<DownloadParams>,
) -> Result<Response, ApiError> {
let pi = user_pi_uri(&state, &object);
let scope = scope_for(&state, &user).await?;
let uri = latest_version_uri(&state, scope, &pi)
.await?
.ok_or_else(|| ApiError::NotFound(pi))?;
download(state, user, uri, object.display_id, $format, params.version).await
}
};
}
versionless_download_routes!(public_sbol_pi, user_sbol_pi, Format::Sbol);
versionless_download_routes!(public_sbolnr_pi, user_sbolnr_pi, Format::SbolNonRecursive);
async fn download(
state: AppState,
user: Option<User>,
uri: String,
display_id: String,
format: Format,
version: Option<String>,
) -> Result<Response, ApiError> {
let scope = scope_for(&state, &user).await?;
download_scoped(state, scope, uri, display_id, format, version).await
}
pub(super) async fn download_scoped(
state: AppState,
scope: GraphScope,
uri: String,
display_id: String,
format: Format,
version: Option<String>,
) -> Result<Response, ApiError> {
let sbol2 = wants_sbol2(version.as_deref())?;
let triples = fetch_closure(&state, &uri, format, scope).await?;
if triples.is_empty() {
return Err(ApiError::NotFound(uri));
}
let attachments = match format {
Format::Omex => Some(PrefetchedAttachments(omex_members(&state, &triples).await?)),
_ => None,
};
let serialized = render(
&triples,
format,
sbol2,
attachments.as_ref().map(|a| a as &dyn OmexAttachmentSource),
)?;
attachment(serialized, &display_id, format.extension())
}
struct PrefetchedAttachments(Vec<OmexAttachment>);
impl OmexAttachmentSource for PrefetchedAttachments {
fn attachments_for(&self, _triples: &[Triple]) -> Result<Vec<OmexAttachment>, DomainError> {
Ok(self.0.clone())
}
}
async fn omex_members(
state: &AppState,
triples: &[Triple],
) -> Result<Vec<OmexAttachment>, ApiError> {
let mut members = Vec::new();
for uri in sbol_db_app::attachment_uris(triples) {
let Some(attachment) = sbol_db_app::read_attachment(triples, &uri) else {
continue;
};
let Some(hash) = attachment.hash else {
continue;
};
let Some(bytes) = state.app.blobs.get(&hash).await? else {
continue;
};
let filename = attachment
.name
.filter(|s| !s.is_empty())
.unwrap_or_else(|| uri.rsplit('/').next().unwrap_or("attachment").to_owned());
let format = attachment
.format
.unwrap_or_else(|| "application/octet-stream".to_owned());
members.push(OmexAttachment {
filename,
format,
bytes,
});
}
Ok(members)
}
async fn fetch_closure(
state: &AppState,
uri: &str,
format: Format,
scope: GraphScope,
) -> Result<Vec<Triple>, ApiError> {
let resolver = std::sync::Arc::new(state.app.federation());
let downloader = Downloader::new(state.app.sparql.clone())
.with_database_prefix(state.app.registry_namespace.database_prefix())
.with_remote_resolver(resolver);
let triples = match format {
Format::SbolNonRecursive => downloader.fetch_non_recursive(uri, scope).await?,
_ => downloader.fetch_recursive(uri, scope).await?,
};
Ok(triples)
}
fn render(
triples: &[Triple],
format: Format,
sbol2: bool,
attachments: Option<&dyn OmexAttachmentSource>,
) -> Result<Serialized, ApiError> {
let serialized = match format {
Format::Sbol | Format::SbolNonRecursive => {
serialize_closure(triples, SerializationFormat::RdfXml, sbol2)?
}
Format::GenBank => serialize_closure(triples, SerializationFormat::GenBank, false)?,
Format::Fasta => serialize_closure(triples, SerializationFormat::Fasta, false)?,
Format::Gff3 => serialize_gff3(triples)?,
Format::Omex => serialize_omex(triples, true, attachments)?,
Format::Summary => serialize_summary(triples)?,
};
Ok(serialized)
}
fn attachment(
serialized: Serialized,
display_id: &str,
extension: &str,
) -> Result<Response, ApiError> {
let filename = format!("{}.{extension}", sanitize_filename(display_id));
Response::builder()
.header(CONTENT_TYPE, serialized.content_type)
.header(
CONTENT_DISPOSITION,
format!("attachment; filename=\"{filename}\""),
)
.header("Access-Control-Expose-Headers", "Content-Disposition")
.body(Body::from(serialized.bytes))
.map_err(|e| ApiError::Domain(DomainError::Serialization(e.to_string())))
}
fn sanitize_filename(display_id: &str) -> String {
display_id
.chars()
.map(|c| match c {
'"' | '\\' | '/' => '_',
other => other,
})
.collect()
}