1#![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
42pub 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 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 Request::get("/api/opc/servers?bridge_host=127.0.0.1:1")
163 .body(Body::empty())
164 .unwrap(),
165 )
166 .await
167 .unwrap();
168 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}