1use axum::{
10 Router,
11 routing::{get, post},
12};
13use http::{HeaderValue, Method};
14use tower_http::cors::CorsLayer;
15use tower_http::trace::TraceLayer;
16use tracing::info;
17
18use crate::handlers;
19
20#[derive(Debug, Clone)]
22pub struct ServerConfig {
23 pub port: u16,
25 pub host: String,
27 pub log_level: String,
29 pub max_body_size: usize,
31 pub request_timeout: u64,
33 pub enable_cors: bool,
35 pub cors_origins: String,
37 pub cors_methods: String,
39 pub cors_headers: String,
41 pub terminology_server: Option<String>,
43}
44
45impl Default for ServerConfig {
46 fn default() -> Self {
47 Self {
48 port: 8080,
49 host: "127.0.0.1".to_string(),
50 log_level: "info".to_string(),
51 max_body_size: 10 * 1024 * 1024, request_timeout: 30,
53 enable_cors: true,
54 cors_origins: "*".to_string(),
55 cors_methods: "GET,POST,PUT,DELETE,OPTIONS".to_string(),
56 cors_headers: "Accept,Accept-Language,Content-Type,Content-Language,Authorization,X-Requested-With,Content-Encoding".to_string(),
57 terminology_server: None,
58 }
59 }
60}
61
62pub fn create_app() -> Router {
66 let config = ServerConfig::default();
67 create_app_with_config(&config)
68}
69
70pub fn create_app_with_config(config: &ServerConfig) -> Router {
76 use axum::extract::DefaultBodyLimit;
77 use std::time::Duration;
78 use tower::ServiceBuilder;
79 use tower_http::compression::CompressionLayer;
80 use tower_http::compression::predicate::{NotForContentType, Predicate, SizeAbove};
81 use tower_http::decompression::RequestDecompressionLayer;
82 use tower_http::timeout::TimeoutLayer;
83
84 let compress_predicate = SizeAbove::new(32)
88 .and(NotForContentType::const_new("application/parquet"))
89 .and(NotForContentType::const_new(
90 "application/vnd.apache.parquet",
91 ))
92 .and(NotForContentType::const_new("application/zip"));
93
94 let mut app = Router::new()
95 .route("/metadata", get(handlers::capability_statement))
97 .route(
104 "/OperationDefinition/sof-sql-run",
105 get(handlers::sql_run_operation_definition),
106 )
107 .route(
119 "/$sql-run",
120 post(handlers::sql_run_handler).get(handlers::sql_run_handler),
121 )
122 .route("/health", get(handlers::health_check))
124 .layer(DefaultBodyLimit::max(config.max_body_size))
129 .layer(RequestDecompressionLayer::new())
132 .layer(CompressionLayer::new().compress_when(compress_predicate))
133 .layer(
135 ServiceBuilder::new()
136 .layer(TimeoutLayer::with_status_code(
137 http::StatusCode::REQUEST_TIMEOUT,
138 Duration::from_secs(config.request_timeout),
139 ))
140 .into_inner(),
141 );
142
143 if config.enable_cors {
145 app = app.layer(build_cors_layer(config));
146 }
147
148 app = app.layer(TraceLayer::new_for_http());
150
151 app = app
153 .merge(helios_observability::metrics::router())
154 .layer(axum::middleware::from_fn(
155 helios_observability::middleware::track,
156 ));
157
158 app
159}
160
161fn build_cors_layer(config: &ServerConfig) -> CorsLayer {
191 use tower_http::cors::{AllowHeaders, AllowMethods, AllowOrigin};
192
193 let mut cors = CorsLayer::new();
194
195 let using_wildcard_origin = config.cors_origins == "*";
197 let using_wildcard_methods = config.cors_methods == "*";
198 let using_wildcard_headers = config.cors_headers == "*";
199 let using_any_wildcard =
200 using_wildcard_origin || using_wildcard_methods || using_wildcard_headers;
201
202 if using_wildcard_origin {
204 cors = cors.allow_origin(AllowOrigin::any());
205 } else {
206 let origins: Vec<HeaderValue> = config
207 .cors_origins
208 .split(',')
209 .map(|s| s.trim())
210 .filter(|s| !s.is_empty())
211 .filter_map(|s| HeaderValue::from_str(s).ok())
212 .collect();
213 cors = cors.allow_origin(origins);
214 }
215
216 if using_wildcard_methods {
218 cors = cors.allow_methods(AllowMethods::any());
219 } else {
220 let methods: Vec<Method> = config
221 .cors_methods
222 .split(',')
223 .map(|s| s.trim().to_uppercase())
224 .filter(|s| !s.is_empty())
225 .filter_map(|s| Method::from_bytes(s.as_bytes()).ok())
226 .collect();
227 cors = cors.allow_methods(methods);
228 }
229
230 if using_wildcard_headers {
232 cors = cors.allow_headers(AllowHeaders::any());
233 } else {
234 let headers: Vec<http::HeaderName> = config
235 .cors_headers
236 .split(',')
237 .map(|s| s.trim())
238 .filter(|s| !s.is_empty())
239 .filter_map(|s| s.parse().ok())
240 .collect();
241 cors = cors.allow_headers(headers);
242 }
243
244 if !using_any_wildcard {
246 cors = cors.allow_credentials(true);
247 } else {
248 info!("CORS: Using wildcards, credentials are disabled for security");
250 }
251
252 cors
253}
254
255#[cfg(test)]
256mod tests {
257 use super::*;
258 use axum::http::StatusCode;
259 use axum_test::TestServer;
260
261 #[tokio::test]
262 async fn test_health_check() {
263 let config = ServerConfig::default();
264 let app = create_app_with_config(&config);
265 let server = TestServer::new(app).unwrap();
266
267 let response = server.get("/health").await;
268
269 assert_eq!(response.status_code(), StatusCode::OK);
270
271 let json: serde_json::Value = response.json();
272 assert_eq!(json["status"], "ok");
273 assert_eq!(json["service"], "sof-server");
274 }
275
276 #[tokio::test]
283 async fn test_unsupported_body_format_returns_400() {
284 let server = TestServer::new(create_app()).unwrap();
285
286 let mut body = run_request_body();
287 body["parameter"]
288 .as_array_mut()
289 .unwrap()
290 .push(serde_json::json!({"name": "_format", "valueCode": "text/plain"}));
291
292 let response = server.post("/$sql-run").json(&body).await;
293
294 assert_eq!(
295 response.status_code(),
296 StatusCode::BAD_REQUEST,
297 "unsupported body _format must be 400, got {}: {}",
298 response.status_code(),
299 response.text()
300 );
301 let json: serde_json::Value = response.json();
302 assert_eq!(json["resourceType"], "OperationOutcome");
303 }
304
305 fn run_request_body() -> serde_json::Value {
309 serde_json::json!({
310 "resourceType": "Parameters",
311 "parameter": [
312 {
313 "name": "subjectResource",
314 "resource": {
315 "resourceType": "ViewDefinition",
316 "status": "active",
317 "resource": "Patient",
318 "select": [{
319 "column": [
320 {"name": "id", "path": "id"},
321 {"name": "gender", "path": "gender"}
322 ]
323 }]
324 }
325 },
326 {
327 "name": "resource",
328 "resource": {
329 "resourceType": "Patient",
330 "id": "example",
331 "gender": "male"
332 }
333 }
334 ]
335 })
336 }
337
338 fn gzip_bytes(input: &[u8]) -> Vec<u8> {
339 use flate2::{Compression, write::GzEncoder};
340 use std::io::Write;
341
342 let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
343 encoder.write_all(input).unwrap();
344 encoder.finish().unwrap()
345 }
346
347 fn gunzip_bytes(input: &[u8]) -> Vec<u8> {
348 use flate2::read::GzDecoder;
349 use std::io::Read;
350
351 let mut decoder = GzDecoder::new(input);
352 let mut output = Vec::new();
353 decoder.read_to_end(&mut output).unwrap();
354 output
355 }
356
357 #[tokio::test]
358 async fn test_gzip_request_body_is_decompressed() {
359 let server = TestServer::new(create_app()).unwrap();
360 let body = serde_json::to_vec(&run_request_body()).unwrap();
361
362 let response = server
363 .post("/$sql-run")
364 .add_header("content-encoding", "gzip")
365 .add_header("accept", "application/json")
366 .content_type("application/json")
367 .bytes(gzip_bytes(&body).into())
368 .await;
369
370 assert_eq!(response.status_code(), StatusCode::OK);
371 let json: serde_json::Value = response.json();
372 assert_eq!(json[0]["id"], "example");
373 }
374
375 #[tokio::test]
376 async fn test_deflate_request_body_is_decompressed() {
377 use flate2::{Compression, write::ZlibEncoder};
378 use std::io::Write;
379
380 let server = TestServer::new(create_app()).unwrap();
381 let body = serde_json::to_vec(&run_request_body()).unwrap();
382 let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default());
383 encoder.write_all(&body).unwrap();
384 let compressed = encoder.finish().unwrap();
385
386 let response = server
387 .post("/$sql-run")
388 .add_header("content-encoding", "deflate")
389 .add_header("accept", "application/json")
390 .content_type("application/json")
391 .bytes(compressed.into())
392 .await;
393
394 assert_eq!(response.status_code(), StatusCode::OK);
395 }
396
397 #[tokio::test]
398 async fn test_uncompressed_request_still_works() {
399 let server = TestServer::new(create_app()).unwrap();
400
401 let response = server
402 .post("/$sql-run")
403 .add_header("accept", "application/json")
404 .json(&run_request_body())
405 .await;
406
407 assert_eq!(response.status_code(), StatusCode::OK);
408 }
409
410 #[tokio::test]
411 async fn test_invalid_gzip_request_body_is_client_error() {
412 let server = TestServer::new(create_app()).unwrap();
413
414 let response = server
415 .post("/$sql-run")
416 .add_header("content-encoding", "gzip")
417 .content_type("application/json")
418 .bytes(b"this is not gzip".to_vec().into())
419 .await;
420
421 assert!(
422 response.status_code().is_client_error(),
423 "invalid gzip body must produce a 4xx, got {}",
424 response.status_code()
425 );
426 }
427
428 #[tokio::test]
429 async fn test_unsupported_content_encoding_is_rejected() {
430 let server = TestServer::new(create_app()).unwrap();
431 let body = serde_json::to_vec(&run_request_body()).unwrap();
432
433 let response = server
434 .post("/$sql-run")
435 .add_header("content-encoding", "compress")
436 .content_type("application/json")
437 .bytes(body.into())
438 .await;
439
440 assert_eq!(response.status_code(), StatusCode::UNSUPPORTED_MEDIA_TYPE);
441 }
442
443 #[tokio::test]
444 async fn test_response_is_gzip_compressed_on_accept_encoding() {
445 let server = TestServer::new(create_app()).unwrap();
446
447 let response = server
448 .get("/metadata")
449 .add_header("accept-encoding", "gzip")
450 .await;
451
452 assert_eq!(response.status_code(), StatusCode::OK);
453 assert_eq!(response.headers().get("content-encoding").unwrap(), "gzip");
454 let vary = response
455 .headers()
456 .get_all("vary")
457 .iter()
458 .map(|v| v.to_str().unwrap().to_ascii_lowercase())
459 .collect::<Vec<_>>()
460 .join(",");
461 assert!(vary.contains("accept-encoding"), "Vary was: {vary}");
462
463 let decompressed = gunzip_bytes(response.as_bytes());
464 let json: serde_json::Value = serde_json::from_slice(&decompressed).unwrap();
465 assert_eq!(json["resourceType"], "CapabilityStatement");
466 }
467
468 #[tokio::test]
469 async fn test_response_is_not_compressed_without_accept_encoding() {
470 let server = TestServer::new(create_app()).unwrap();
471
472 let response = server.get("/metadata").await;
473
474 assert_eq!(response.status_code(), StatusCode::OK);
475 assert!(response.headers().get("content-encoding").is_none());
476 let json: serde_json::Value = response.json();
477 assert_eq!(json["resourceType"], "CapabilityStatement");
478 }
479
480 #[tokio::test]
481 async fn test_parquet_response_is_not_http_compressed() {
482 let server = TestServer::new(create_app()).unwrap();
483 let mut body = run_request_body();
484 body["parameter"].as_array_mut().unwrap().insert(
485 0,
486 serde_json::json!({"name": "_format", "valueCode": "application/parquet"}),
487 );
488
489 let response = server
490 .post("/$sql-run")
491 .add_header("accept-encoding", "gzip")
492 .json(&body)
493 .await;
494
495 assert_eq!(response.status_code(), StatusCode::OK);
496 assert_eq!(
497 response.headers().get("content-type").unwrap(),
498 "application/vnd.apache.parquet"
499 );
500 assert!(response.headers().get("content-encoding").is_none());
502 assert!(response.as_bytes().starts_with(b"PAR1"));
503 }
504
505 #[tokio::test]
506 async fn test_body_limit_applies_to_decompressed_size() {
507 let config = ServerConfig {
508 max_body_size: 1024,
509 ..Default::default()
510 };
511 let server = TestServer::new(create_app_with_config(&config)).unwrap();
512
513 let mut body = run_request_body();
516 body["parameter"][1]["resource"]["name"] =
517 serde_json::json!([{"family": "a".repeat(64 * 1024)}]);
518 let raw = serde_json::to_vec(&body).unwrap();
519 let compressed = gzip_bytes(&raw);
520 assert!(
521 compressed.len() < 1024,
522 "test payload must compress below the limit"
523 );
524
525 let response = server
526 .post("/$sql-run")
527 .add_header("content-encoding", "gzip")
528 .content_type("application/json")
529 .bytes(compressed.into())
530 .await;
531
532 assert_eq!(response.status_code(), StatusCode::PAYLOAD_TOO_LARGE);
533 }
534
535 #[tokio::test]
541 async fn test_fhir_format_returns_parameters() {
542 let server = TestServer::new(create_app()).unwrap();
543 let mut body = run_request_body();
544 body["parameter"].as_array_mut().unwrap().insert(
545 0,
546 serde_json::json!({"name": "_format", "valueCode": "fhir"}),
547 );
548
549 let response = server.post("/$sql-run").json(&body).await;
550
551 assert_eq!(
552 response.status_code(),
553 StatusCode::OK,
554 "{}",
555 response.text()
556 );
557 assert_eq!(
558 response.headers().get("content-type").unwrap(),
559 "application/fhir+json"
560 );
561 let v: serde_json::Value = response.json();
562 assert_eq!(v["resourceType"], "Parameters");
563 let rows = v["parameter"].as_array().expect("parameter array");
564 assert_eq!(rows.len(), 1);
565 assert_eq!(rows[0]["name"], "row");
566 let parts = rows[0]["part"].as_array().expect("row parts");
567 assert!(
568 parts
569 .iter()
570 .any(|p| p["name"] == "gender" && p["valueString"] == "male"),
571 "row must carry the gender part: {v}"
572 );
573 }
574
575 #[tokio::test]
576 async fn test_accept_fhir_json_without_format_selects_fhir() {
577 let server = TestServer::new(create_app()).unwrap();
578 let response = server
579 .post("/$sql-run")
580 .add_header("accept", "application/fhir+json")
581 .json(&run_request_body())
582 .await;
583
584 assert_eq!(
585 response.status_code(),
586 StatusCode::OK,
587 "{}",
588 response.text()
589 );
590 let v: serde_json::Value = response.json();
591 assert_eq!(
592 v["resourceType"], "Parameters",
593 "Accept: application/fhir+json must select the fhir format: {v}"
594 );
595 }
596
597 #[tokio::test]
598 async fn test_accept_fhir_json_with_csv_format_returns_binary_envelope() {
599 use base64::Engine as _;
600 let server = TestServer::new(create_app()).unwrap();
601 let mut body = run_request_body();
602 body["parameter"].as_array_mut().unwrap().insert(
603 0,
604 serde_json::json!({"name": "_format", "valueCode": "csv"}),
605 );
606
607 let response = server
608 .post("/$sql-run")
609 .add_header("accept", "application/fhir+json")
610 .json(&body)
611 .await;
612
613 assert_eq!(
614 response.status_code(),
615 StatusCode::OK,
616 "{}",
617 response.text()
618 );
619 assert_eq!(
620 response.headers().get("content-type").unwrap(),
621 "application/fhir+json"
622 );
623 let v: serde_json::Value = response.json();
624 assert_eq!(v["resourceType"], "Binary");
625 assert_eq!(v["contentType"], "text/csv");
626 let decoded = base64::engine::general_purpose::STANDARD
627 .decode(v["data"].as_str().expect("Binary.data"))
628 .expect("Binary.data must be base64");
629 let csv = String::from_utf8(decoded).expect("decoded csv is utf8");
630 assert!(csv.contains("male"), "decoded csv: {csv}");
631 }
632
633 #[tokio::test]
634 async fn test_accept_fhir_xml_returns_406() {
635 let server = TestServer::new(create_app()).unwrap();
636 let response = server
637 .post("/$sql-run")
638 .add_header("accept", "application/fhir+xml")
639 .json(&run_request_body())
640 .await;
641
642 assert_eq!(
643 response.status_code(),
644 StatusCode::NOT_ACCEPTABLE,
645 "{}",
646 response.text()
647 );
648 let v: serde_json::Value = response.json();
649 assert_eq!(v["resourceType"], "OperationOutcome");
650 }
651}