Skip to main content

doido_controller/
server.rs

1//! Convenience entry point for booting an application's HTTP server.
2
3use crate::config::{self, ServerConfig};
4use crate::stack::MiddlewareStack;
5
6/// Resolve the listen address, letting explicit `bind`/`port` overrides (e.g.
7/// `doido server --port 4000`) win over `config/<env>.yml`.
8fn resolve_addr(server: &ServerConfig, bind: Option<&str>, port: Option<u16>) -> String {
9    let bind = bind.unwrap_or(&server.bind);
10    let port = port.unwrap_or(server.port);
11    format!("{bind}:{port}")
12}
13
14/// Whether this project is API-only (`[app] api_only = true` in
15/// `config/application.toml`, written by `doido new --api`). Read relative to the
16/// working directory, like `config/<env>.yml`. The same marker is read at compile
17/// time by the route macros; here it tunes the middleware stack. Missing/unreadable
18/// file means "not an API".
19fn api_only() -> bool {
20    std::fs::read_to_string("config/application.toml")
21        .map(|contents| marker_is_api_only(&contents))
22        .unwrap_or(false)
23}
24
25/// Scan a flat `application.toml` for an `api_only = true` assignment (comments
26/// stripped, value matched case-insensitively).
27fn marker_is_api_only(contents: &str) -> bool {
28    contents.lines().any(|line| {
29        let line = line.split('#').next().unwrap_or("").trim();
30        matches!(line.split_once('='), Some((k, v)) if k.trim() == "api_only" && v.trim().eq_ignore_ascii_case("true"))
31    })
32}
33
34/// Boots the HTTP server for `router` using `config/<env>.yml` (the environment
35/// comes from [`doido_core::Environment::get_env`]). Missing config falls back
36/// to `0.0.0.0:3000`.
37pub async fn start_server(router: crate::axum::Router) -> std::io::Result<()> {
38    start_server_with(router, None, None).await
39}
40
41/// Like [`start_server`], but with optional `bind`/`port` overrides that take
42/// precedence over the config file (used by `doido server --port`/`--bind`).
43pub async fn start_server_with(
44    router: crate::axum::Router,
45    bind: Option<String>,
46    port: Option<u16>,
47) -> std::io::Result<()> {
48    let config = config::load();
49    let addr = resolve_addr(config.server(), bind.as_deref(), port);
50
51    // Apply the always-on middleware (request/response logging + panic
52    // recovery) so every request is traced through the global subscriber. In
53    // API-only projects, HTML-only middleware (e.g. CSRF) is skipped.
54    let router = MiddlewareStack::default()
55        .with_api_only(api_only())
56        .apply(router);
57
58    let listener = tokio::net::TcpListener::bind(&addr).await?;
59    tracing::info!("listening on http://{addr}");
60    tracing::info!("routes:\n{}", crate::route_table::format_routes());
61    crate::axum::serve(listener, router).await
62}
63
64#[cfg(test)]
65mod tests {
66    use super::*;
67
68    #[test]
69    fn resolve_addr_prefers_overrides_then_config() {
70        let server = ServerConfig {
71            bind: "0.0.0.0".into(),
72            port: 3000,
73        };
74        assert_eq!(resolve_addr(&server, None, None), "0.0.0.0:3000");
75        assert_eq!(resolve_addr(&server, None, Some(4000)), "0.0.0.0:4000");
76        assert_eq!(
77            resolve_addr(&server, Some("127.0.0.1"), None),
78            "127.0.0.1:3000"
79        );
80        assert_eq!(
81            resolve_addr(&server, Some("127.0.0.1"), Some(8080)),
82            "127.0.0.1:8080"
83        );
84    }
85
86    #[test]
87    fn marker_detects_api_only() {
88        assert!(marker_is_api_only("[app]\nname = \"x\"\napi_only = true\n"));
89        assert!(marker_is_api_only("api_only = TRUE  # marker"));
90        assert!(!marker_is_api_only("[app]\nname = \"x\"\n"));
91        assert!(!marker_is_api_only("api_only = false"));
92        assert!(!marker_is_api_only("# api_only = true"));
93    }
94}