Skip to main content

zart_api/
server.rs

1//! HTTP server setup and lifecycle management.
2
3use crate::routes;
4use crate::state::AppState;
5use axum::Router;
6use std::sync::Arc;
7use tokio_util::sync::CancellationToken;
8use tower_http::cors::CorsLayer;
9use tower_http::trace::TraceLayer;
10use tracing::info;
11use zart::DurableApi;
12
13/// The Zart API server.
14///
15/// Wraps an Axum router and exposes durable execution management over HTTP.
16pub struct ApiServer {
17    /// TCP address to bind, e.g. `"0.0.0.0:8080"`.
18    addr: String,
19    /// The durable execution backend.
20    durable: Arc<dyn DurableApi>,
21    /// CancellationToken for graceful shutdown.
22    cancellation: Option<CancellationToken>,
23    /// Route prefix for the main API (default: `"/api/v1"`).
24    api_prefix: String,
25    /// Route prefix for the admin API (default: `"/zart/admin/v1"`).
26    admin_prefix: String,
27    /// Whether to mount Swagger UI (`/swagger-ui`) and schema (`/openapi.json`).
28    #[cfg(feature = "openapi")]
29    swagger_ui: bool,
30}
31
32fn default_api_prefix() -> String {
33    std::env::var("ZART_API_PREFIX").unwrap_or_else(|_| "/api/v1".to_string())
34}
35
36fn default_admin_prefix() -> String {
37    std::env::var("ZART_ADMIN_PREFIX").unwrap_or_else(|_| "/zart/admin/v1".to_string())
38}
39
40impl ApiServer {
41    /// Create a new API server bound to `addr`.
42    #[must_use]
43    pub fn new(addr: impl Into<String>, durable: Arc<dyn DurableApi>) -> Self {
44        Self {
45            addr: addr.into(),
46            durable,
47            cancellation: None,
48            api_prefix: default_api_prefix(),
49            admin_prefix: default_admin_prefix(),
50            #[cfg(feature = "openapi")]
51            swagger_ui: false,
52        }
53    }
54
55    /// Create a new API server with a cancellation token for graceful shutdown.
56    #[must_use]
57    pub fn with_cancellation(
58        addr: impl Into<String>,
59        durable: Arc<dyn DurableApi>,
60        cancellation: CancellationToken,
61    ) -> Self {
62        Self {
63            addr: addr.into(),
64            durable,
65            cancellation: Some(cancellation),
66            api_prefix: default_api_prefix(),
67            admin_prefix: default_admin_prefix(),
68            #[cfg(feature = "openapi")]
69            swagger_ui: false,
70        }
71    }
72
73    /// Override the main API route prefix (default: `"/api/v1"`).
74    ///
75    /// Takes precedence over the `ZART_API_PREFIX` environment variable.
76    #[must_use]
77    pub fn with_api_prefix(mut self, prefix: impl Into<String>) -> Self {
78        self.api_prefix = prefix.into();
79        self
80    }
81
82    /// Override the admin API route prefix (default: `"/zart/admin/v1"`).
83    ///
84    /// Takes precedence over the `ZART_ADMIN_PREFIX` environment variable.
85    #[must_use]
86    pub fn with_admin_prefix(mut self, prefix: impl Into<String>) -> Self {
87        self.admin_prefix = prefix.into();
88        self
89    }
90
91    /// Mount Swagger UI at `/swagger-ui` and serve the OpenAPI schema at `/openapi.json`.
92    ///
93    /// Only available with the `openapi` feature.
94    #[cfg(feature = "openapi")]
95    #[must_use]
96    pub fn with_swagger_ui(mut self) -> Self {
97        self.swagger_ui = true;
98        self
99    }
100
101    /// Build the Axum router with main API routes and middleware.
102    ///
103    /// Health checks (`/healthz`, `/readyz`) and metrics (`/metrics`) are always
104    /// mounted at the root, unaffected by [`Self::with_api_prefix`].
105    ///
106    /// The admin router is not included; mount it separately via
107    /// [`crate::admin_routes::admin_router`] passing the `admin_prefix` configured
108    /// via [`Self::with_admin_prefix`].
109    pub fn router(&self) -> Router {
110        let state = AppState::new(self.durable.clone());
111        #[allow(unused_mut)]
112        let mut app = routes::api_router(state, &self.api_prefix);
113
114        #[cfg(feature = "openapi")]
115        if self.swagger_ui {
116            use crate::openapi::build_openapi;
117            use utoipa_swagger_ui::SwaggerUi;
118            let openapi = build_openapi(&self.api_prefix, &self.admin_prefix, None, None);
119            app = app.merge(SwaggerUi::new("/swagger-ui").url("/openapi.json", openapi));
120        }
121
122        app.layer(TraceLayer::new_for_http())
123            .layer(CorsLayer::permissive())
124    }
125
126    /// Start listening and serving requests.
127    ///
128    /// Blocks until the server is shut down.
129    pub async fn serve(self) -> Result<(), std::io::Error> {
130        info!(addr = %self.addr, "Zart API server starting");
131        let router = self.router();
132        let listener = tokio::net::TcpListener::bind(&self.addr).await?;
133
134        // Move cancellation token into a variable we can control
135        let cancellation = self.cancellation;
136
137        if let Some(cancellation) = cancellation {
138            info!("Zart API server configured with graceful shutdown");
139            // Create the shutdown signal future and keep cancellation alive
140            let shutdown_signal = async move {
141                cancellation.cancelled().await;
142            };
143            axum::serve(listener, router)
144                .with_graceful_shutdown(shutdown_signal)
145                .await
146        } else {
147            axum::serve(listener, router).await
148        }
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155    use async_trait::async_trait;
156    use std::time::Duration;
157    use zart::error::SchedulerError;
158    use zart::{ExecutionRecord, ExecutionStats, ListExecutionsParams};
159    use zart_scheduler::ScheduleResult;
160
161    struct NullApi;
162
163    #[async_trait]
164    impl DurableApi for NullApi {
165        async fn start(
166            &self,
167            _: &str,
168            _: &str,
169            _: serde_json::Value,
170        ) -> Result<ScheduleResult, SchedulerError> {
171            unimplemented!()
172        }
173        async fn cancel(&self, _: &str) -> Result<bool, SchedulerError> {
174            unimplemented!()
175        }
176        async fn status(&self, _: &str) -> Result<ExecutionRecord, SchedulerError> {
177            unimplemented!()
178        }
179        async fn wait(
180            &self,
181            _: &str,
182            _: Duration,
183            _: Option<Duration>,
184        ) -> Result<ExecutionRecord, SchedulerError> {
185            unimplemented!()
186        }
187        async fn offer_event(
188            &self,
189            _: &str,
190            _: &str,
191            _: serde_json::Value,
192        ) -> Result<(), SchedulerError> {
193            unimplemented!()
194        }
195        async fn list_executions(
196            &self,
197            _: ListExecutionsParams,
198        ) -> Result<Vec<ExecutionRecord>, SchedulerError> {
199            unimplemented!()
200        }
201        async fn stats(&self) -> Result<ExecutionStats, SchedulerError> {
202            Ok(ExecutionStats::default())
203        }
204    }
205
206    #[test]
207    fn server_builds_router() {
208        let server = ApiServer::new("0.0.0.0:8080", Arc::new(NullApi));
209        let _ = server.router();
210    }
211
212    #[test]
213    fn custom_prefixes_accepted() {
214        let server = ApiServer::new("0.0.0.0:8080", Arc::new(NullApi))
215            .with_api_prefix("/v2")
216            .with_admin_prefix("/ops/zart");
217        assert_eq!(server.api_prefix, "/v2");
218        assert_eq!(server.admin_prefix, "/ops/zart");
219        let _ = server.router();
220    }
221
222    #[test]
223    fn default_prefixes_are_correct() {
224        let server = ApiServer::new("0.0.0.0:8080", Arc::new(NullApi));
225        // env var may or may not be set; just verify the field is populated
226        assert!(!server.api_prefix.is_empty());
227        assert!(!server.admin_prefix.is_empty());
228    }
229
230    #[cfg(feature = "openapi")]
231    #[test]
232    fn with_swagger_ui_builds_router() {
233        let server = ApiServer::new("0.0.0.0:8080", Arc::new(NullApi)).with_swagger_ui();
234        let _ = server.router();
235    }
236
237    #[cfg(feature = "openapi")]
238    #[test]
239    fn swagger_ui_with_custom_prefix_builds_router() {
240        let server = ApiServer::new("0.0.0.0:8080", Arc::new(NullApi))
241            .with_api_prefix("/v2")
242            .with_swagger_ui();
243        let _ = server.router();
244    }
245}