Skip to main content

synd_api/serve/
mod.rs

1use std::{path::Path, time::Duration};
2
3use axum::{
4    BoxError, Extension, Router,
5    error_handling::HandleErrorLayer,
6    http::{StatusCode, header::AUTHORIZATION},
7    response::IntoResponse,
8    routing::{get, post},
9};
10use tokio::net::TcpListener;
11#[cfg(unix)]
12use tokio::net::UnixListener;
13use tokio_metrics::TaskMonitor;
14use tower::{ServiceBuilder, limit::ConcurrencyLimitLayer, timeout::TimeoutLayer};
15use tower_http::{
16    cors::CorsLayer, limit::RequestBodyLimitLayer, sensitive_headers::SetSensitiveHeadersLayer,
17};
18use tracing::{debug, info};
19
20use crate::{
21    Result, config,
22    dependency::Dependency,
23    gql::{self, SyndSchema},
24    serve::layer::{authenticate, request_metrics::RequestMetricsLayer, trace},
25    session::{DaemonSessionConfig, DaemonSessionSweeper, SessionIdleShutdown},
26    shutdown::Shutdown,
27};
28
29pub mod auth;
30mod probe;
31mod session;
32
33pub mod layer;
34
35pub async fn rustls_config_from_pem_files(
36    certificate: impl AsRef<Path>,
37    private_key: impl AsRef<Path>,
38) -> Result<axum_server::tls_rustls::RustlsConfig> {
39    axum_server::tls_rustls::RustlsConfig::from_pem_file(certificate, private_key)
40        .await
41        .map_err(|source| crate::Error::TlsConfig { source })
42}
43
44pub struct ServeOptions {
45    pub timeout: Duration,
46    pub body_limit_bytes: usize,
47    pub concurrency_limit: usize,
48    pub daemon_sessions: DaemonSessionConfig,
49}
50
51impl ServeOptions {
52    #[must_use]
53    pub fn with_daemon_sessions(mut self, daemon_sessions: DaemonSessionConfig) -> Self {
54        self.daemon_sessions = daemon_sessions;
55        self
56    }
57}
58
59impl Default for ServeOptions {
60    fn default() -> Self {
61        Self {
62            timeout: Duration::from_secs(30),
63            body_limit_bytes: config::serve::DEFAULT_REQUEST_BODY_LIMIT_BYTES,
64            concurrency_limit: config::serve::DEFAULT_REQUEST_CONCURRENCY_LIMIT,
65            daemon_sessions: DaemonSessionConfig::default(),
66        }
67    }
68}
69
70#[derive(Clone)]
71pub(crate) struct Context {
72    pub gql_monitor: TaskMonitor,
73    pub schema: SyndSchema,
74}
75
76/// Start api server
77pub async fn serve(listener: TcpListener, dep: Dependency, shutdown: Shutdown) -> Result<()> {
78    let ApiService { router, tls_config } = build_service(dep, &shutdown);
79
80    info!("Serving...");
81
82    let listener = listener.into_std()?;
83    let handle = shutdown.into_handle();
84
85    if let Some(tls_config) = tls_config {
86        axum_server::from_tcp_rustls(listener, tls_config)?
87            .handle(handle)
88            .serve(router.into_make_service())
89            .await?;
90    } else {
91        axum_server::from_tcp(listener)?
92            .handle(handle)
93            .serve(router.into_make_service())
94            .await?;
95    }
96
97    info!("Shutdown complete");
98
99    Ok(())
100}
101
102#[cfg(unix)]
103pub async fn serve_unix(listener: UnixListener, dep: Dependency, shutdown: Shutdown) -> Result<()> {
104    let daemon_sessions = dep.serve_options.daemon_sessions;
105    let sessions = dep
106        .sessions
107        .with_lease_policy(daemon_sessions.lease_policy())
108        .with_idle_shutdown(SessionIdleShutdown::new(
109            daemon_sessions.idle_shutdown_grace(),
110            shutdown.clone(),
111        ));
112    let ApiService { router, .. } = build_service(dep, &shutdown);
113    let shutdown_requested = shutdown.cancellation_token();
114    let _session_sweeper =
115        DaemonSessionSweeper::new(sessions.clone(), shutdown_requested.clone()).spawn();
116    let router = router
117        .route("/session/open", post(session::open))
118        .route("/session/renew", post(session::renew))
119        .route("/session/close", post(session::close))
120        .route(synd_protocol::daemon::STATUS_PATH, get(control::status))
121        .route("/daemon/shutdown", post(control::shutdown))
122        .layer(Extension(sessions))
123        .layer(Extension(shutdown));
124
125    debug!("Serving on Unix socket");
126
127    axum::serve(listener, router)
128        .with_graceful_shutdown(async move {
129            shutdown_requested.cancelled().await;
130        })
131        .await?;
132
133    debug!("Unix socket server shutdown complete");
134
135    Ok(())
136}
137
138struct ApiService {
139    router: Router,
140    tls_config: Option<axum_server::tls_rustls::RustlsConfig>,
141}
142
143fn build_service(dep: Dependency, shutdown: &Shutdown) -> ApiService {
144    let Dependency {
145        authenticator,
146        registry,
147        tls_config,
148        serve_options:
149            ServeOptions {
150                timeout: request_timeout,
151                body_limit_bytes: request_body_limit_bytes,
152                concurrency_limit,
153                daemon_sessions: _,
154            },
155        monitors,
156        sessions: _,
157    } = dep;
158
159    let cx = Context {
160        gql_monitor: monitors.graphql_task_monitor(),
161        schema: gql::schema_builder().data(registry).finish(),
162    };
163
164    tokio::spawn(monitors.emit_metrics(
165        config::metrics::MONITOR_INTERVAL,
166        shutdown.cancellation_token(),
167    ));
168
169    let router = Router::new()
170        .route("/graphql", post(gql::handler::graphql))
171        .route("/graphql/ws", get(gql::handler::graphql_ws))
172        .layer(Extension(cx))
173        .layer(authenticate::AuthenticateLayer::new(authenticator))
174        .route("/graphql", get(gql::handler::graphiql))
175        .layer(
176            ServiceBuilder::new()
177                .layer(SetSensitiveHeadersLayer::new(std::iter::once(
178                    AUTHORIZATION,
179                )))
180                .layer(trace::layer())
181                .layer(HandleErrorLayer::new(handle_middleware_error))
182                .layer(TimeoutLayer::new(request_timeout))
183                .layer(ConcurrencyLimitLayer::new(concurrency_limit))
184                .layer(RequestBodyLimitLayer::new(request_body_limit_bytes))
185                .layer(CorsLayer::new()),
186        )
187        .route(config::serve::HEALTH_CHECK_PATH, get(probe::healthcheck))
188        .layer(RequestMetricsLayer::new())
189        .fallback(not_found);
190
191    ApiService { router, tls_config }
192}
193
194mod control {
195    use axum::{Extension, Json, http::StatusCode};
196    use synd_protocol::daemon::DaemonStatusResponse;
197
198    use crate::session::DaemonSessions;
199    use crate::shutdown::{Shutdown, ShutdownReason};
200
201    pub(super) async fn status(
202        Extension(sessions): Extension<DaemonSessions>,
203    ) -> Json<DaemonStatusResponse> {
204        Json(DaemonStatusResponse::new(sessions.status()))
205    }
206
207    pub(super) async fn shutdown(Extension(shutdown): Extension<Shutdown>) -> StatusCode {
208        shutdown.shutdown_with_reason(ShutdownReason::ApiRequest);
209        StatusCode::ACCEPTED
210    }
211}
212
213async fn handle_middleware_error(err: BoxError) -> (StatusCode, String) {
214    if err.is::<tower::timeout::error::Elapsed>() {
215        (
216            StatusCode::REQUEST_TIMEOUT,
217            "Request took too long".to_string(),
218        )
219    } else {
220        (
221            StatusCode::INTERNAL_SERVER_ERROR,
222            format!("Unhandled internal error: {err}"),
223        )
224    }
225}
226
227async fn not_found() -> impl IntoResponse {
228    StatusCode::NOT_FOUND
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234
235    #[tokio::test]
236    async fn error_mapping() {
237        assert_eq!(
238            handle_middleware_error(Box::new(tower::timeout::error::Elapsed::new()))
239                .await
240                .0,
241            StatusCode::REQUEST_TIMEOUT
242        );
243        assert_eq!(
244            handle_middleware_error(Box::new(std::io::Error::from(
245                std::io::ErrorKind::OutOfMemory
246            )))
247            .await
248            .0,
249            StatusCode::INTERNAL_SERVER_ERROR,
250        );
251
252        assert_eq!(
253            not_found().await.into_response().status(),
254            StatusCode::NOT_FOUND
255        );
256    }
257}