Skip to main content

bhtune_server/
lib.rs

1//! `bhtune-server` — the Axum HTTP/REST adapter over `bhtune-core`/`bhtune-db`, and the host
2//! for the built React SPA (embedded via `rust-embed`, `server-embed-spa`). See AGENTS.md's
3//! "Web app architecture" section for why this, rather than a desktop GUI, is the primary
4//! v1 GUI adapter.
5//!
6//! Split into a lib (this crate, `bhtune_server`) and a thin `main.rs` binary shell so route
7//! handlers are directly testable via [`tower::ServiceExt::oneshot`] against
8//! [`build_router`]'s output, with no bound TCP socket needed -- the same lib/bin split
9//! `bhtune-cli` already uses for the same reason.
10
11pub mod active_run;
12pub mod cli;
13pub mod error;
14pub mod openapi;
15pub mod routes;
16pub mod run;
17pub mod security;
18pub mod service;
19mod spa;
20pub mod state;
21
22#[cfg(test)]
23mod test_support;
24
25use utoipa::OpenApi as _;
26use utoipa_scalar::Servable as _;
27
28pub use state::AppState;
29
30/// Assembles every route module into one [`axum::Router`], ready to serve or to drive
31/// directly in a test via `tower::ServiceExt::oneshot`.
32///
33/// Alongside the JSON API routes, this mounts the OpenAPI contract itself two ways: the raw
34/// document at `GET /api/openapi.json` (for tooling -- CI's spec-diff gate, and the generated
35/// `frontend/` TS client) and an interactive Scalar UI at `/api/docs` (for a human exploring
36/// the API in a browser). [`utoipa_scalar::Scalar::with_url`] returns a state-generic
37/// `axum::Router<S>` with the UI's one route already attached, so it merges in directly
38/// rather than needing its own handler function.
39///
40/// Everything that isn't one of those routes falls through to [`spa::static_handler`], which
41/// serves the built React SPA -- so this one router is the whole HTTP surface of a real
42/// `bhtune-server` deployment, API and UI alike. None of the merged sub-routers set their own
43/// fallback (axum panics if two merged routers each declare one), so this is the only place
44/// `.fallback` is called.
45pub fn build_router(state: AppState) -> axum::Router {
46    let mode = state.mode;
47    let policy = state.demo_policy;
48    let router = if mode == bhtune_cli::config::ServerMode::Demo {
49        axum::Router::new()
50            .merge(routes::health::router())
51            .merge(routes::capabilities::router())
52            .merge(routes::demo::router(policy))
53    } else {
54        axum::Router::new()
55            .merge(routes::health::router())
56            .merge(routes::capabilities::router())
57            .merge(routes::templates::router())
58            .merge(routes::history::router())
59            .merge(routes::runs::router())
60            .merge(routes::draft::router())
61            .merge(routes::config::router())
62            .merge(routes::stream::router())
63            .merge(routes::opc::router())
64            .route("/api/openapi.json", axum::routing::get(openapi_json))
65            .merge(utoipa_scalar::Scalar::with_url(
66                "/api/docs",
67                openapi::ApiDoc::openapi(),
68            ))
69    };
70    router
71        .fallback(spa::static_handler)
72        .layer(axum::middleware::from_fn_with_state(
73            state.clone(),
74            security::origin_and_security_headers,
75        ))
76        .with_state(state)
77}
78
79async fn openapi_json() -> axum::Json<utoipa::openapi::OpenApi> {
80    axum::Json(openapi::ApiDoc::openapi())
81}
82
83#[cfg(test)]
84mod tests {
85    use super::*;
86    use axum::body::{Body, to_bytes};
87    use axum::http::{Request, StatusCode};
88    use tower::ServiceExt;
89
90    #[tokio::test]
91    async fn build_router_serves_every_merged_route_module() {
92        let app = build_router(test_support::in_memory_state().await);
93
94        let health = app
95            .clone()
96            .oneshot(Request::get("/api/health").body(Body::empty()).unwrap())
97            .await
98            .unwrap();
99        assert_eq!(health.status(), StatusCode::OK);
100
101        let templates = app
102            .clone()
103            .oneshot(Request::get("/api/templates").body(Body::empty()).unwrap())
104            .await
105            .unwrap();
106        assert_eq!(templates.status(), StatusCode::OK);
107
108        let runs = app
109            .clone()
110            .oneshot(Request::get("/api/runs").body(Body::empty()).unwrap())
111            .await
112            .unwrap();
113        assert_eq!(runs.status(), StatusCode::OK);
114
115        let config = app
116            .clone()
117            .oneshot(Request::get("/api/config").body(Body::empty()).unwrap())
118            .await
119            .unwrap();
120        assert_eq!(config.status(), StatusCode::OK);
121
122        // `history::router()` registers `GET /api/runs` and `runs::router()` registers
123        // `POST /api/runs` at that same path string -- proving axum actually merges the two
124        // method routers onto one path (rather than the second `.merge()` silently
125        // dropping/overwriting the first) is exactly the design question this route was
126        // built to resolve. A malformed body still reaches the handler and fails with `400`
127        // (validation), not `404`/`405` (routing) -- routing succeeding is all this
128        // assertion cares about.
129        let post_runs = app
130            .clone()
131            .oneshot(
132                Request::post("/api/runs")
133                    .header(axum::http::header::CONTENT_TYPE, "application/json")
134                    .body(Body::from("{}"))
135                    .unwrap(),
136            )
137            .await
138            .unwrap();
139        assert_ne!(post_runs.status(), StatusCode::NOT_FOUND);
140        assert_ne!(post_runs.status(), StatusCode::METHOD_NOT_ALLOWED);
141
142        let opc_servers = app
143            .clone()
144            .oneshot(
145                // Port 1 (a privileged/unlikely-bound port, matching `bhtune-cli`'s own
146                // `servers_connect_failure_surfaces_as_an_error` test precedent) so this
147                // resolves via a fast, deterministic connection refusal rather than the
148                // default bridge host, which nothing is guaranteed to be listening on either
149                // way but isn't a *guaranteed-immediate* refusal.
150                Request::get("/api/opc/servers?bridge_host=127.0.0.1:1")
151                    .body(Body::empty())
152                    .unwrap(),
153            )
154            .await
155            .unwrap();
156        // No bridge host is configured/reachable in this in-memory test state, so this is a
157        // `400` (a diagnostic, client-actionable failure -- see `routes::opc::with_timeout`),
158        // not a routing failure; the point of this assertion is that the route exists at all.
159        assert_ne!(opc_servers.status(), StatusCode::NOT_FOUND);
160        assert_ne!(opc_servers.status(), StatusCode::METHOD_NOT_ALLOWED);
161
162        let unknown_api = app
163            .clone()
164            .oneshot(
165                Request::get("/api/does-not-exist")
166                    .body(Body::empty())
167                    .unwrap(),
168            )
169            .await
170            .unwrap();
171        assert_eq!(unknown_api.status(), StatusCode::NOT_FOUND);
172        assert_eq!(
173            unknown_api
174                .headers()
175                .get(axum::http::header::CONTENT_TYPE)
176                .unwrap(),
177            "application/json"
178        );
179        let unknown_api_body = to_bytes(unknown_api.into_body(), usize::MAX).await.unwrap();
180        let unknown_api_json: serde_json::Value =
181            serde_json::from_slice(&unknown_api_body).unwrap();
182        assert_eq!(
183            unknown_api_json["error"],
184            "API route not found: /api/does-not-exist"
185        );
186
187        let openapi_json = app
188            .clone()
189            .oneshot(
190                Request::get("/api/openapi.json")
191                    .body(Body::empty())
192                    .unwrap(),
193            )
194            .await
195            .unwrap();
196        assert_eq!(openapi_json.status(), StatusCode::OK);
197        let bytes = to_bytes(openapi_json.into_body(), usize::MAX)
198            .await
199            .unwrap();
200        let spec: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
201        assert_eq!(spec["info"]["title"], "BHTune API");
202        assert_eq!(spec["paths"]["/api/health"]["get"]["tags"][0], "health");
203
204        let docs = app
205            .oneshot(Request::get("/api/docs").body(Body::empty()).unwrap())
206            .await
207            .unwrap();
208        assert_eq!(docs.status(), StatusCode::OK);
209        let content_type = docs
210            .headers()
211            .get(axum::http::header::CONTENT_TYPE)
212            .unwrap()
213            .to_str()
214            .unwrap()
215            .to_string();
216        assert!(content_type.starts_with("text/html"));
217    }
218}