1pub 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
30pub 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 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 Request::get("/api/opc/servers?bridge_host=127.0.0.1:1")
151 .body(Body::empty())
152 .unwrap(),
153 )
154 .await
155 .unwrap();
156 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}