use axum::body::Bytes;
use axum::extract::{Query, State};
use axum::http::header::CONTENT_TYPE;
use axum::http::HeaderMap;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::Json;
use sbol_db_app::{AlignMode, AlignOptions, Hit, SequenceAlignment};
use sbol_db_core::SbolObjectRecord;
use sbol_db_sparql::{
explorer_count_results, explorer_graph_scope, explorer_search_results, ExplorerQuery,
ExplorerSearchQuery, GraphScope, Paging,
};
use sbol_db_storage::{EnqueueOutcome, ListJobsFilter, NewJob};
use serde::Deserialize;
use serde_json::{json, Map, Value};
use crate::{ApiError, AppState};
pub(crate) async fn route(
state: &AppState,
query: ExplorerQuery,
scope: GraphScope,
source: &str,
) -> Result<Response, ApiError> {
match query {
ExplorerQuery::Browse { paging }
| ExplorerQuery::Structured { paging }
| ExplorerQuery::Text { paging, .. } => text(state, source, paging, scope).await,
ExplorerQuery::Similar { uri, paging } => similar(state, &uri, paging, &scope).await,
ExplorerQuery::Sequence {
sequence,
exact,
paging,
} => sequence_search(state, &sequence, exact, paging, &scope).await,
}
}
async fn similar(
state: &AppState,
uri: &str,
paging: Paging,
scope: &GraphScope,
) -> Result<Response, ApiError> {
if paging.count {
let count = state.app.sequence().similar_count(uri, scope).await?;
return Ok(json_response(explorer_count_results(count)));
}
let hits = state.app.sequence().similar(uri, scope).await?;
let iris: Vec<&str> = hits.iter().map(|h| h.iri.as_str()).collect();
let records = state.service.get_objects_by_iris(&iris).await?;
let by_iri = index_by_iri(&records);
let bindings = hits
.iter()
.skip(paging.offset)
.take(paging.limit)
.map(|hit| {
Value::Object(base_binding(
&hit.iri,
by_iri.get(hit.iri.as_str()).copied(),
))
})
.collect();
Ok(json_response(explorer_search_results(bindings)))
}
async fn sequence_search(
state: &AppState,
sequence: &str,
exact: bool,
paging: Paging,
scope: &GraphScope,
) -> Result<Response, ApiError> {
let options = AlignOptions {
mode: if exact {
AlignMode::Exact
} else {
AlignMode::GlobalAlign
},
..AlignOptions::default()
};
let hits = state.app.sequence().align(sequence, options, scope).await?;
if paging.count {
return Ok(json_response(explorer_count_results(hits.len())));
}
let iris: Vec<&str> = hits.iter().map(|h| h.sequence_iri.as_str()).collect();
let records = state.service.get_objects_by_iris(&iris).await?;
let by_iri = index_by_iri(&records);
let bindings = hits
.iter()
.skip(paging.offset)
.take(paging.limit)
.map(|hit| alignment_binding(hit, by_iri.get(hit.sequence_iri.as_str()).copied()))
.collect();
Ok(json_response(explorer_search_results(bindings)))
}
async fn text(
state: &AppState,
source: &str,
paging: Paging,
scope: GraphScope,
) -> Result<Response, ApiError> {
let query = ExplorerSearchQuery::parse(source, paging.count)?;
let page = state.app.explorer_search(&query, &paging, scope).await?;
if paging.count {
return Ok(json_response(explorer_count_results(page.total)));
}
let bindings = page
.hits
.iter()
.map(|row| {
let mut binding = hit_binding(&row.hit);
if let Some(role) = &row.role {
binding["role"] = uri_node(role);
}
if let Some(kind) = &row.sbol_type {
binding["sbolType"] = uri_node(kind);
}
binding
})
.collect();
Ok(json_response(explorer_search_results(bindings)))
}
const EXPLORER_CONFIG_KEY: &str = "sbolexplorer_compat";
const REINDEX_KIND: &str = "rebuild_search_index";
#[derive(Debug, Deserialize)]
pub(crate) struct ExplorerRequest {
query: Option<String>,
#[serde(rename = "default-graph-uri")]
default_graph_uri: Option<String>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct UpdateRequest {
subject: Option<String>,
}
pub(crate) async fn endpoint(
State(state): State<AppState>,
Query(params): Query<ExplorerRequest>,
headers: HeaderMap,
) -> Result<Response, ApiError> {
let Some(query) = params.query else {
return Ok((
[(CONTENT_TYPE, "text/plain; charset=utf-8")],
"sbol-db SBOLExplorer compatibility listener\n",
)
.into_response());
};
if let Some(recognized) = sbol_db_sparql::recognize_explorer(&query) {
let scope = explorer_graph_scope(&query, params.default_graph_uri.as_deref());
return route(&state, recognized, scope, &query).await;
}
if matches!(
explorer_graph_scope(&query, params.default_graph_uri.as_deref()),
GraphScope::Only(ref graphs) if graphs.is_empty()
) {
return Err(ApiError::BadRequest(
"Explorer query has no allowed dataset".to_owned(),
));
}
crate::routes::sparql_get(
State(state),
Query(crate::routes::SparqlGetParams {
query: Some(query),
format: None,
default_graph_uri: params.default_graph_uri,
}),
headers,
)
.await
.map(IntoResponse::into_response)
}
fn default_config() -> Value {
json!({
"engine": "sbol-db",
"uclust_identity": "0.8",
"elasticsearch_index_name": "native",
"pagerank_tolerance": "0.0001",
"elasticsearch_endpoint": "native://embedded/",
"sparql_endpoint": "native://shared-store/sparql?",
"last_update_start": "managed by sbol-db jobs",
"last_update_end": "managed by sbol-db jobs",
"distributed_search": false,
"which_search": "vsearch",
"autoUpdateIndex": false,
"updateTimeInDays": "1"
})
}
pub(crate) async fn get_config(State(state): State<AppState>) -> Result<Json<Value>, ApiError> {
Ok(Json(
state
.app
.config_service()
.get(EXPLORER_CONFIG_KEY)
.await?
.unwrap_or_else(default_config),
))
}
pub(crate) async fn set_config(
State(state): State<AppState>,
Json(update): Json<Value>,
) -> Result<Json<Value>, ApiError> {
let update = update
.as_object()
.ok_or_else(|| ApiError::BadRequest("Explorer config must be a JSON object".to_owned()))?;
let mut merged = state
.app
.config_service()
.get(EXPLORER_CONFIG_KEY)
.await?
.unwrap_or_else(default_config)
.as_object()
.cloned()
.unwrap_or_default();
merged.extend(update.clone());
let merged = Value::Object(merged);
state
.app
.config_service()
.set(true, EXPLORER_CONFIG_KEY, &merged)
.await
.map_err(|error| ApiError::BadRequest(error.to_string()))?;
Ok(Json(merged))
}
pub(crate) async fn update(
State(state): State<AppState>,
Query(params): Query<UpdateRequest>,
) -> Response {
let job = NewJob {
kind: REINDEX_KIND.to_owned(),
payload: json!({}),
queue: None,
priority: None,
max_attempts: None,
idempotency_key: None,
available_at: None,
parent_job_id: None,
correlation_id: None,
};
match state.jobs.enqueue(job).await {
Ok(EnqueueOutcome::Inserted(job)) | Ok(EnqueueOutcome::AlreadyExists(job)) => {
let message = params
.subject
.map(|subject| format!("Successfully scheduled refresh for: {subject}"))
.unwrap_or_else(|| "Successfully scheduled entire index update".to_owned());
(
StatusCode::ACCEPTED,
[("X-SBOL-DB-Job-Id", job.id.to_string())],
message,
)
.into_response()
}
Err(error) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("unable to schedule index update: {error}"),
)
.into_response(),
}
}
pub(crate) async fn incremental_update(_body: Bytes) -> impl IntoResponse {
"Successfully acknowledged incremental update"
}
pub(crate) async fn incremental_remove() -> impl IntoResponse {
"Successfully acknowledged incremental remove"
}
pub(crate) async fn incremental_remove_collection() -> impl IntoResponse {
"Successfully acknowledged incremental collection remove"
}
pub(crate) async fn info() -> impl IntoResponse {
(
[(CONTENT_TYPE, "text/plain; charset=utf-8")],
"sbol-db SBOLExplorer compatibility listener is ready\n",
)
}
pub(crate) async fn indexing_info(State(state): State<AppState>) -> Response {
let filter = ListJobsFilter {
kind: Some(REINDEX_KIND.to_owned()),
limit: 1,
..ListJobsFilter::default()
};
match state.jobs.list(&filter).await {
Ok(jobs) => {
let body = jobs.first().map_or_else(
|| "No index update has been scheduled\n".to_owned(),
|job| {
format!(
"job_id={} status={} created_at={} finished_at={}\n",
job.id,
job.status.as_db_str(),
job.created_at.to_rfc3339(),
job.finished_at
.map(|timestamp| timestamp.to_rfc3339())
.unwrap_or_else(|| "-".to_owned())
)
},
);
([(CONTENT_TYPE, "text/plain; charset=utf-8")], body).into_response()
}
Err(error) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("unable to read index lifecycle: {error}"),
)
.into_response(),
}
}
fn index_by_iri(
records: &[SbolObjectRecord],
) -> std::collections::HashMap<&str, &SbolObjectRecord> {
records.iter().map(|r| (r.iri.as_str(), r)).collect()
}
fn alignment_binding(hit: &SequenceAlignment, record: Option<&SbolObjectRecord>) -> Value {
let mut binding = base_binding(&hit.sequence_iri, record);
binding.insert(
"percentMatch".to_owned(),
literal(&hit.percent_match.to_string()),
);
binding.insert(
"strandAlignment".to_owned(),
literal(&hit.strand.to_string()),
);
binding.insert("CIGAR".to_owned(), literal(&hit.cigar));
Value::Object(binding)
}
fn hit_binding(hit: &Hit) -> Value {
let mut binding = Map::new();
binding.insert("subject".to_owned(), uri_node(&hit.subject));
insert_literal(&mut binding, "displayId", hit.display_id.as_deref());
insert_literal(&mut binding, "version", hit.version.as_deref());
insert_literal(&mut binding, "name", hit.name.as_deref());
insert_literal(&mut binding, "description", hit.description.as_deref());
if let Some(type_iri) = &hit.type_iri {
binding.insert("type".to_owned(), uri_node(type_iri));
}
Value::Object(binding)
}
fn base_binding(iri: &str, record: Option<&SbolObjectRecord>) -> Map<String, Value> {
let mut binding = Map::new();
binding.insert("subject".to_owned(), uri_node(iri));
if let Some(record) = record {
insert_literal(&mut binding, "displayId", record.display_id.as_deref());
insert_literal(&mut binding, "name", record.name.as_deref());
insert_literal(&mut binding, "description", record.description.as_deref());
binding.insert("type".to_owned(), uri_node(&record.sbol_class));
}
binding
}
fn json_response(value: Value) -> Response {
(
[(CONTENT_TYPE, "application/sparql-results+json")],
Json(value),
)
.into_response()
}
fn uri_node(value: &str) -> Value {
json!({ "type": "uri", "value": value })
}
fn literal(value: &str) -> Value {
json!({ "type": "literal", "value": value })
}
fn insert_literal(binding: &mut Map<String, Value>, var: &str, value: Option<&str>) {
if let Some(value) = value {
binding.insert(var.to_owned(), literal(value));
}
}