use crate::routes;
use crate::state::AppState;
use axum::Router;
use std::sync::Arc;
use tokio_util::sync::CancellationToken;
use tower_http::cors::CorsLayer;
use tower_http::trace::TraceLayer;
use tracing::info;
use zart::DurableApi;
pub struct ApiServer {
addr: String,
durable: Arc<dyn DurableApi>,
cancellation: Option<CancellationToken>,
api_prefix: String,
admin_prefix: String,
#[cfg(feature = "openapi")]
swagger_ui: bool,
}
fn default_api_prefix() -> String {
std::env::var("ZART_API_PREFIX").unwrap_or_else(|_| "/api/v1".to_string())
}
fn default_admin_prefix() -> String {
std::env::var("ZART_ADMIN_PREFIX").unwrap_or_else(|_| "/zart/admin/v1".to_string())
}
impl ApiServer {
#[must_use]
pub fn new(addr: impl Into<String>, durable: Arc<dyn DurableApi>) -> Self {
Self {
addr: addr.into(),
durable,
cancellation: None,
api_prefix: default_api_prefix(),
admin_prefix: default_admin_prefix(),
#[cfg(feature = "openapi")]
swagger_ui: false,
}
}
#[must_use]
pub fn with_cancellation(
addr: impl Into<String>,
durable: Arc<dyn DurableApi>,
cancellation: CancellationToken,
) -> Self {
Self {
addr: addr.into(),
durable,
cancellation: Some(cancellation),
api_prefix: default_api_prefix(),
admin_prefix: default_admin_prefix(),
#[cfg(feature = "openapi")]
swagger_ui: false,
}
}
#[must_use]
pub fn with_api_prefix(mut self, prefix: impl Into<String>) -> Self {
self.api_prefix = prefix.into();
self
}
#[must_use]
pub fn with_admin_prefix(mut self, prefix: impl Into<String>) -> Self {
self.admin_prefix = prefix.into();
self
}
#[cfg(feature = "openapi")]
#[must_use]
pub fn with_swagger_ui(mut self) -> Self {
self.swagger_ui = true;
self
}
pub fn router(&self) -> Router {
let state = AppState::new(self.durable.clone());
#[allow(unused_mut)]
let mut app = routes::api_router(state, &self.api_prefix);
#[cfg(feature = "openapi")]
if self.swagger_ui {
use crate::openapi::build_openapi;
use utoipa_swagger_ui::SwaggerUi;
let openapi = build_openapi(&self.api_prefix, &self.admin_prefix, None, None);
app = app.merge(SwaggerUi::new("/swagger-ui").url("/openapi.json", openapi));
}
app.layer(TraceLayer::new_for_http())
.layer(CorsLayer::permissive())
}
pub async fn serve(self) -> Result<(), std::io::Error> {
info!(addr = %self.addr, "Zart API server starting");
let router = self.router();
let listener = tokio::net::TcpListener::bind(&self.addr).await?;
let cancellation = self.cancellation;
if let Some(cancellation) = cancellation {
info!("Zart API server configured with graceful shutdown");
let shutdown_signal = async move {
cancellation.cancelled().await;
};
axum::serve(listener, router)
.with_graceful_shutdown(shutdown_signal)
.await
} else {
axum::serve(listener, router).await
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use async_trait::async_trait;
use std::time::Duration;
use zart::error::SchedulerError;
use zart::{ExecutionRecord, ExecutionStats, ListExecutionsParams};
use zart_scheduler::ScheduleResult;
struct NullApi;
#[async_trait]
impl DurableApi for NullApi {
async fn start(
&self,
_: &str,
_: &str,
_: serde_json::Value,
) -> Result<ScheduleResult, SchedulerError> {
unimplemented!()
}
async fn cancel(&self, _: &str) -> Result<bool, SchedulerError> {
unimplemented!()
}
async fn status(&self, _: &str) -> Result<ExecutionRecord, SchedulerError> {
unimplemented!()
}
async fn wait(
&self,
_: &str,
_: Duration,
_: Option<Duration>,
) -> Result<ExecutionRecord, SchedulerError> {
unimplemented!()
}
async fn offer_event(
&self,
_: &str,
_: &str,
_: serde_json::Value,
) -> Result<(), SchedulerError> {
unimplemented!()
}
async fn list_executions(
&self,
_: ListExecutionsParams,
) -> Result<Vec<ExecutionRecord>, SchedulerError> {
unimplemented!()
}
async fn stats(&self) -> Result<ExecutionStats, SchedulerError> {
Ok(ExecutionStats::default())
}
}
#[test]
fn server_builds_router() {
let server = ApiServer::new("0.0.0.0:8080", Arc::new(NullApi));
let _ = server.router();
}
#[test]
fn custom_prefixes_accepted() {
let server = ApiServer::new("0.0.0.0:8080", Arc::new(NullApi))
.with_api_prefix("/v2")
.with_admin_prefix("/ops/zart");
assert_eq!(server.api_prefix, "/v2");
assert_eq!(server.admin_prefix, "/ops/zart");
let _ = server.router();
}
#[test]
fn default_prefixes_are_correct() {
let server = ApiServer::new("0.0.0.0:8080", Arc::new(NullApi));
assert!(!server.api_prefix.is_empty());
assert!(!server.admin_prefix.is_empty());
}
#[cfg(feature = "openapi")]
#[test]
fn with_swagger_ui_builds_router() {
let server = ApiServer::new("0.0.0.0:8080", Arc::new(NullApi)).with_swagger_ui();
let _ = server.router();
}
#[cfg(feature = "openapi")]
#[test]
fn swagger_ui_with_custom_prefix_builds_router() {
let server = ApiServer::new("0.0.0.0:8080", Arc::new(NullApi))
.with_api_prefix("/v2")
.with_swagger_ui();
let _ = server.router();
}
}