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