1use 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
13pub struct ApiServer {
17 addr: String,
19 durable: Arc<dyn DurableApi>,
21 cancellation: Option<CancellationToken>,
23 api_prefix: String,
25 admin_prefix: String,
27 #[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 #[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 #[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 #[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 #[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 #[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 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 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 let cancellation = self.cancellation;
136
137 if let Some(cancellation) = cancellation {
138 info!("Zart API server configured with graceful shutdown");
139 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 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}