Skip to main content

helios_sof/
app.rs

1//! Axum application wiring for the `sof-server` binary.
2//!
3//! This module builds the same router the `sof-server` binary serves, so
4//! that integration tests exercise production wiring instead of a
5//! hand-maintained stub. The binary (`src/server.rs`) is left with only
6//! argument parsing and `main`; everything that decides *what* gets served
7//! lives here.
8
9use 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/// Server configuration options
21#[derive(Debug, Clone)]
22pub struct ServerConfig {
23    /// Port to bind the server to
24    pub port: u16,
25    /// Host address to bind to
26    pub host: String,
27    /// Log level for the server
28    pub log_level: String,
29    /// Maximum request body size in bytes
30    pub max_body_size: usize,
31    /// Request timeout in seconds
32    pub request_timeout: u64,
33    /// Whether to enable CORS
34    pub enable_cors: bool,
35    /// Allowed CORS origins (comma-separated list, "*" for any)
36    pub cors_origins: String,
37    /// Allowed CORS methods (comma-separated list, "*" for any)
38    pub cors_methods: String,
39    /// Allowed CORS headers (comma-separated list, "*" for any)
40    pub cors_headers: String,
41    /// Terminology server URL for FHIRPath terminology functions (memberOf, subsumes, etc.)
42    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, // 10MB
52            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
62/// Create the axum application with all routes and configuration
63/// Create the application router with default configuration
64/// This is used for testing and can be used for custom server implementations
65pub fn create_app() -> Router {
66    let config = ServerConfig::default();
67    create_app_with_config(&config)
68}
69
70/// Create the application router using the given configuration.
71///
72/// This is what the `sof-server` binary calls after parsing its arguments,
73/// and what integration tests call (via [`create_app`] or directly) to
74/// exercise the real production router.
75pub 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    // Compress responses on `Accept-Encoding`, but never re-compress Parquet
85    // or ZIP output — both are already compressed, so HTTP-level compression
86    // would only burn CPU for no size win.
87    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        // FHIR endpoints
96        .route("/metadata", get(handlers::capability_statement))
97        // This server's own OperationDefinition for `$sql-run`. It supports a
98        // subset of the guide's parameters (no `subjectCanonical`,
99        // `subjectReference`, `context` or `source`), and
100        // operations-capability.html#partial-operation-support requires such a
101        // server to publish its own definition, with `base` naming the guide's,
102        // and to cite that from its CapabilityStatement.
103        .route(
104            "/OperationDefinition/sof-sql-run",
105            get(handlers::sql_run_operation_definition),
106        )
107        // `$sql-run` is invoked at the **system level only**
108        // (`system=true, type=false, instance=false`). The pre-ballot
109        // continuous build also offered type- and instance-level
110        // `$viewdefinition-run` endpoints; those were never published and are
111        // gone.
112        //
113        // GET is permitted whenever every supplied parameter is primitive,
114        // which is what keeps the operation usable from a browser or a command
115        // line. sof-server is stateless and resolves no subject by URL, so GET
116        // will normally surface a 400 — but the route exists so clients can
117        // negotiate the method correctly.
118        .route(
119            "/$sql-run",
120            post(handlers::sql_run_handler).get(handlers::sql_run_handler),
121        )
122        // Health check endpoint
123        .route("/health", get(handlers::health_check))
124        // Add body size limit. The decompression layer below replaces the
125        // request body before extractors read it, so this limit applies to
126        // the *decompressed* bytes — a small highly-compressed payload
127        // cannot bypass SOF_MAX_BODY_SIZE.
128        .layer(DefaultBodyLimit::max(config.max_body_size))
129        // Decompress request bodies sent with `Content-Encoding` (gzip,
130        // deflate, br, zstd); unsupported encodings get 415.
131        .layer(RequestDecompressionLayer::new())
132        .layer(CompressionLayer::new().compress_when(compress_predicate))
133        // Add request timeout
134        .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    // Add CORS if enabled
144    if config.enable_cors {
145        app = app.layer(build_cors_layer(config));
146    }
147
148    // Add tracing
149    app = app.layer(TraceLayer::new_for_http());
150
151    // Observability: `/metrics` (state-free) + per-request metrics/trace span.
152    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
161/// Build CORS layer from configuration
162///
163/// This function creates a CORS middleware layer based on the server configuration.
164/// It supports flexible CORS configuration:
165///
166/// - **Origins**: Use "*" for any origin, or provide a comma-separated list of allowed origins
167/// - **Methods**: Use "*" for any method, or provide a comma-separated list (e.g., "GET,POST,OPTIONS")
168/// - **Headers**: Use "*" for any header, or provide a comma-separated list of allowed headers
169///
170/// # Examples
171///
172/// ```text
173/// # Allow any origin, method, and header (without credentials)
174/// cors_origins = "*"
175/// cors_methods = "*"
176/// cors_headers = "*"
177///
178/// # Allow specific origins (with credentials)
179/// cors_origins = "https://example.com,https://app.example.com"
180///
181/// # Allow specific methods
182/// cors_methods = "GET,POST,OPTIONS"
183///
184/// # Allow specific headers
185/// cors_headers = "Content-Type,Authorization,X-Requested-With"
186/// ```
187///
188/// Note: When using wildcards (*), credentials are disabled for security.
189/// To use credentials, specify exact origins, methods, and headers.
190fn build_cors_layer(config: &ServerConfig) -> CorsLayer {
191    use tower_http::cors::{AllowHeaders, AllowMethods, AllowOrigin};
192
193    let mut cors = CorsLayer::new();
194
195    // Check if we're using wildcards
196    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    // Configure origins
203    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    // Configure methods
217    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    // Configure headers
231    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    // Only allow credentials if not using wildcards
245    if !using_any_wildcard {
246        cors = cors.allow_credentials(true);
247    } else {
248        // Log a warning if wildcards are used
249        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    // ── Unsupported `_format` ─────────────────────────────────────────────
277
278    /// Spec (operations-common, Output Formats): an unsupported `_format`
279    /// value SHALL be rejected with 400 Bad Request + OperationOutcome —
280    /// for the body parameter as well as the query parameter. (The stub
281    /// suite in `tests/` used to entrench 415 for the body path.)
282    #[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    // ── HTTP compression ──────────────────────────────────────────────────
306
307    /// A minimal valid `$viewdefinition-run` Parameters body.
308    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        // Parquet is already compressed — the gzip layer must skip it.
501        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        // ~64 KiB of repeated text compresses to well under the 1 KiB limit,
514        // but the decompressed body must still be rejected with 413.
515        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    // =========================================================================
536    // SoF v2 Common Operation Behavior (spec PR #365): `fhir` output format,
537    // Binary-envelope representation, and FHIR-XML rejection.
538    // =========================================================================
539
540    #[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}