mod auth;
mod docs;
mod error;
mod export;
#[cfg(feature = "lab")]
mod lab;
pub mod metrics;
mod routes;
pub use error::ApiError;
pub use export::export_subject_rdf;
#[cfg(feature = "lab")]
pub use lab::SchemaCache;
pub use metrics::Metrics;
#[cfg(feature = "lab")]
pub use sbol_db_storage::{BackendKind, Capabilities, MaintenanceStyle};
use std::sync::Arc;
use std::time::Duration;
use axum::extract::{DefaultBodyLimit, Request};
use axum::http::StatusCode;
use axum::response::IntoResponse;
use axum::routing::{get, post};
use axum::{Json, Router};
use sbol_db_sparql::{SparqlEngine, SparqlUpdateEngine};
#[cfg(feature = "lab")]
use sbol_db_storage::{DbStats, LabStore, LsmStats, SqlConsole};
use sbol_db_storage::{JobQueue, SbolStore};
use serde_json::json;
use tower_http::limit::RequestBodyLimitLayer;
use tower_http::timeout::TimeoutLayer;
#[derive(Clone)]
pub struct AppState {
pub service: Arc<dyn SbolStore>,
pub sparql: Arc<SparqlEngine>,
pub sparql_update: Arc<SparqlUpdateEngine>,
pub metrics: Arc<Metrics>,
pub jobs: Arc<dyn JobQueue>,
#[cfg(feature = "lab")]
pub lab: Arc<dyn LabStore>,
pub config: ServerConfig,
#[cfg(feature = "lab")]
pub backend_kind: BackendKind,
#[cfg(feature = "lab")]
pub sql_console: Option<Arc<dyn SqlConsole>>,
#[cfg(feature = "lab")]
pub db_stats: Option<Arc<dyn DbStats>>,
#[cfg(feature = "lab")]
pub lsm_stats: Option<Arc<dyn LsmStats>>,
#[cfg(feature = "lab")]
pub schema_cache: Arc<lab::SchemaCache>,
}
#[derive(Debug, Clone)]
pub struct ServerConfig {
pub request_timeout: Duration,
pub max_body_bytes: usize,
pub lab_enabled: bool,
pub lab_sql_timeout_ms_max: u64,
pub lab_sql_row_cap_max: u32,
pub sparql_auth_user: String,
pub sparql_auth_password: String,
pub sparql_auth_disabled: bool,
}
impl Default for ServerConfig {
fn default() -> Self {
Self {
request_timeout: Duration::from_secs(60),
max_body_bytes: 32 * 1024 * 1024,
lab_enabled: true,
lab_sql_timeout_ms_max: 60_000,
lab_sql_row_cap_max: 50_000,
sparql_auth_user: "dba".to_owned(),
sparql_auth_password: "dba".to_owned(),
sparql_auth_disabled: false,
}
}
}
impl ServerConfig {
pub fn from_env() -> Self {
let defaults = Self::default();
Self {
request_timeout: std::env::var("SBOL_DB_REQUEST_TIMEOUT_SECS")
.ok()
.and_then(|s| s.parse::<u64>().ok())
.map(Duration::from_secs)
.unwrap_or(defaults.request_timeout),
max_body_bytes: std::env::var("SBOL_DB_MAX_BODY_BYTES")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(defaults.max_body_bytes),
lab_enabled: std::env::var("SBOL_DB_LAB_ENABLED")
.ok()
.map(|v| parse_bool(&v))
.unwrap_or(defaults.lab_enabled),
lab_sql_timeout_ms_max: std::env::var("SBOL_DB_LAB_SQL_TIMEOUT_MS_MAX")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(defaults.lab_sql_timeout_ms_max),
lab_sql_row_cap_max: std::env::var("SBOL_DB_LAB_SQL_ROW_CAP_MAX")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(defaults.lab_sql_row_cap_max),
sparql_auth_user: std::env::var("SBOL_DB_SPARQL_AUTH_USER")
.unwrap_or(defaults.sparql_auth_user),
sparql_auth_password: std::env::var("SBOL_DB_SPARQL_AUTH_PASSWORD")
.unwrap_or(defaults.sparql_auth_password),
sparql_auth_disabled: std::env::var("SBOL_DB_SPARQL_AUTH_DISABLED")
.ok()
.map(|v| parse_bool(&v))
.unwrap_or(defaults.sparql_auth_disabled),
}
}
}
fn parse_bool(s: &str) -> bool {
matches!(
s.trim().to_ascii_lowercase().as_str(),
"1" | "true" | "yes" | "on"
)
}
impl AppState {
pub fn invalidate_lab_caches(&self) {
#[cfg(feature = "lab")]
{
let cache = self.schema_cache.clone();
tokio::spawn(async move { cache.invalidate_all().await });
}
}
#[cfg(feature = "lab")]
pub fn capabilities(&self) -> Capabilities {
let maintenance = if self.db_stats.is_some() {
Some(MaintenanceStyle::Relational)
} else if self.lsm_stats.is_some() {
Some(MaintenanceStyle::Lsm)
} else {
None
};
let is_postgres = self.backend_kind == BackendKind::Postgres;
Capabilities {
sql_console: self.sql_console.is_some(),
relational_schema: self.db_stats.is_some(),
maintenance,
slow_query_stats: is_postgres,
activity_and_locks: is_postgres,
}
}
#[cfg(feature = "lab")]
pub fn require_sql_console(&self) -> Result<&Arc<dyn SqlConsole>, ApiError> {
self.sql_console
.as_ref()
.ok_or_else(|| ApiError::Unavailable(SQL_UNSUPPORTED.to_owned()))
}
#[cfg(feature = "lab")]
pub fn require_db_stats(&self) -> Result<&Arc<dyn DbStats>, ApiError> {
self.db_stats.as_ref().ok_or_else(|| {
ApiError::Unavailable(
"this lab page requires a relational engine (Postgres or SQLite); the server is \
running on a different backend"
.to_owned(),
)
})
}
#[cfg(feature = "lab")]
pub fn require_lsm_stats(&self) -> Result<&Arc<dyn LsmStats>, ApiError> {
self.lsm_stats.as_ref().ok_or_else(|| {
ApiError::Unavailable(
"this lab page requires the RocksDB backend; the server is running on a \
different backend"
.to_owned(),
)
})
}
}
#[cfg(feature = "lab")]
const SQL_UNSUPPORTED: &str =
"the SQL console requires a SQL engine (Postgres or SQLite); the server is running on a \
key-value backend";
pub fn router(state: AppState, config: ServerConfig) -> Router {
let api = Router::new()
.route("/healthz", get(routes::healthz))
.route("/readyz", get(routes::readyz))
.route("/metrics", get(metrics::metrics_handler))
.route("/docs", get(docs::docs_html))
.route("/openapi.json", get(docs::openapi_json))
.route("/graphs", post(routes::create_graph))
.route("/graphs/bulk", post(routes::create_graphs_bulk))
.route("/graphs/:id", get(routes::get_graph))
.route("/objects", get(routes::get_object_by_iri))
.route("/objects/list", get(routes::list_objects))
.route("/objects/lookup", post(routes::lookup_objects))
.route("/objects/:id/rdf", get(routes::export_object))
.route("/objects/neighborhood", get(routes::neighborhood))
.route("/objects/neighborhood.rdf", get(routes::neighborhood_rdf))
.route("/sparql", get(routes::sparql_get).post(routes::sparql_post))
.route(
"/sequences/search",
get(routes::sequence_search).post(routes::sequence_search_batch),
)
.route(
"/ontology",
get(routes::ontology_list).post(routes::ontology_load),
)
.route("/ontology/term", get(routes::ontology_term))
.route("/ontology/terms", get(routes::ontology_terms))
.route("/ontology/descendants", get(routes::ontology_descendants))
.route("/jobs", get(routes::list_jobs).post(routes::enqueue_job))
.route("/jobs/:id", get(routes::get_job))
.route("/jobs/:id/attempts", get(routes::list_job_attempts))
.route("/jobs/:id/logs", get(routes::list_job_logs))
.route("/jobs/:id/cancel", post(routes::cancel_job))
.route_layer(axum::middleware::from_fn(metrics::track_metrics));
let graph_crud = get(routes::graph_store_get)
.post(routes::graph_store_post)
.put(routes::graph_store_put)
.delete(routes::graph_store_delete);
let authed = Router::new()
.route(
"/sparql-auth",
get(routes::sparql_auth).post(routes::sparql_auth),
)
.route("/sparql-graph-crud-auth", graph_crud.clone())
.route("/sparql-graph-crud-auth/", graph_crud)
.route_layer(axum::middleware::from_fn_with_state(
state.clone(),
auth::require_auth,
));
let app = mount_lab(api.merge(authed), &config)
.fallback(not_found_handler)
.with_state(state);
app.layer(DefaultBodyLimit::max(config.max_body_bytes))
.layer(RequestBodyLimitLayer::new(config.max_body_bytes))
.layer(TimeoutLayer::with_status_code(
axum::http::StatusCode::REQUEST_TIMEOUT,
config.request_timeout,
))
}
async fn not_found_handler(req: Request) -> impl IntoResponse {
let method = req.method().clone();
let uri = req.uri().clone();
tracing::warn!(%method, path = %uri.path(), "404: no route matched");
(
StatusCode::NOT_FOUND,
Json(json!({
"type": "not_found",
"title": "not_found",
"status": 404,
"detail": format!("no route registered for {method} {}", uri.path()),
})),
)
}
#[cfg(feature = "lab")]
fn mount_lab(router: Router<AppState>, config: &ServerConfig) -> Router<AppState> {
if !config.lab_enabled {
tracing::info!("lab disabled via SBOL_DB_LAB_ENABLED");
return router;
}
tracing::info!(
ui_built = sbol_db_ui::is_built(),
"lab enabled; mounting JSON API at /lab/api and SPA at /lab"
);
router
.nest("/lab/api", lab::router())
.nest_service("/lab", sbol_db_ui::router())
}
#[cfg(not(feature = "lab"))]
fn mount_lab(router: Router<AppState>, _config: &ServerConfig) -> Router<AppState> {
router
}