Skip to main content

haste_server/
server.rs

1use crate::{
2    SERVER_VERSION,
3    auth_n::{self, certificates::get_certification_provider, middleware::jwt::User},
4    config::ServerConfig,
5    fhir_client::ServerCTX,
6    fhir_http::{HTTPBody, HTTPRequest, http_request_to_fhir_request},
7    jobs, mcp,
8    middleware::{
9        errors::{REQUEST_ID_HEADER, log_operationoutcome_errors, operation_outcome_error_handle},
10        security_headers::security_headers,
11    },
12    openapi,
13    services::{ConfigError, ServerState, create_services, get_pool},
14    static_assets::{create_static_server, root_asset_route},
15};
16use axum::{
17    Extension, Router, ServiceExt,
18    body::{Body, Bytes, to_bytes},
19    extract::{DefaultBodyLimit, OriginalUri, Path, State},
20    http::Request,
21    http::{HeaderMap, HeaderName, HeaderValue, Method, Uri, header},
22    middleware::from_fn,
23    response::{IntoResponse, Response},
24    routing::{any, get, post},
25};
26use axum_client_ip::ClientIpSource;
27use haste_fhir_client::{
28    FHIRClient,
29    request::{FHIRCapabilitiesResponse, FHIRRequest, FHIRResponse},
30};
31use haste_fhir_model::r4::generated::terminology::IssueType;
32use haste_fhir_operation_error::OperationOutcomeError;
33use haste_fhir_search::SearchEngine;
34use haste_fhir_terminology::FHIRTerminology;
35use haste_jwt::{ProjectId, TenantId};
36use haste_repository::{Repository, types::SupportedFHIRVersions, utilities::generate_id};
37use sentry::integrations::tower::NewSentryLayer;
38use serde::Deserialize;
39use std::net::SocketAddr;
40use std::sync::Arc;
41use tower::{Layer, ServiceBuilder};
42use tower_http::{catch_panic::CatchPanicLayer, cors::AllowHeaders, normalize_path::NormalizePath};
43use tower_http::{
44    compression::{
45        CompressionLayer,
46        predicate::{DefaultPredicate, NotForContentType, Predicate},
47    },
48    cors::{Any, CorsLayer},
49    normalize_path::NormalizePathLayer,
50    request_id::{MakeRequestUuid, PropagateRequestIdLayer, SetRequestIdLayer},
51    set_header::SetResponseHeaderLayer,
52    trace::TraceLayer,
53};
54use tower_sessions::{
55    Expiry, SessionManagerLayer,
56    cookie::{SameSite, time::Duration},
57};
58use tower_sessions_sqlx_store::PostgresStore;
59
60/// The project a route addresses. The FHIR version is not part of it: it comes
61/// from the project, and the URL only optionally repeats it.
62#[derive(Deserialize)]
63struct ProjectPath {
64    tenant: TenantId,
65    project: ProjectId,
66}
67
68/// Splits an optional leading FHIR version off a path under the FHIR root.
69///
70/// Both `r4/Patient/123` and `Patient/123` address the same resource. No FHIR
71/// resource type is spelled like a version, so a first segment that names one
72/// is the version and never a location.
73fn split_fhir_version(path: &str) -> (Option<SupportedFHIRVersions>, &str) {
74    let path = path.trim_start_matches('/');
75    let (head, rest) = path.split_once('/').unwrap_or((path, ""));
76
77    match SupportedFHIRVersions::from_url_segment(head) {
78        Some(version) => (Some(version), rest),
79        None => (None, path),
80    }
81}
82
83/// The FHIR version a request works in.
84///
85/// It belongs to the project, and the token carries it, so the URL can only
86/// agree with it. A request that names a different version is asking for
87/// something this project does not serve, and is refused rather than quietly
88/// answered in the wrong version.
89fn request_fhir_version(
90    user: &User,
91    path: &ProjectPath,
92    requested: Option<SupportedFHIRVersions>,
93) -> Result<SupportedFHIRVersions, OperationOutcomeError> {
94    let project_version = user.claims.fhir_version.clone();
95
96    if let Some(requested) = requested
97        && requested != project_version
98    {
99        return Err(OperationOutcomeError::error(
100            IssueType::not_supported(),
101            format!(
102                "Project '{}' is served as FHIR {}, not {}.",
103                path.project.as_ref(),
104                project_version,
105                requested
106            ),
107        ));
108    }
109
110    Ok(project_version)
111}
112
113/// Whether a search's `query` asks for `_pretty=true`. `_format` must name
114/// JSON, the only format served
115/// (<https://hl7.org/fhir/R4/http.html#parameters>); a raw `+` in
116/// `application/fhir+json` decodes to a space. A repeated parameter's last
117/// value counts.
118fn pretty_requested(query: &str) -> Result<bool, OperationOutcomeError> {
119    let (mut format, mut pretty) = (None, None);
120    for (name, value) in url::form_urlencoded::parse(query.as_bytes()) {
121        match name.as_ref() {
122            "_format" => format = Some(value),
123            "_pretty" => pretty = Some(value),
124            _ => {}
125        }
126    }
127    if let Some(format) = format.as_deref()
128        && !matches!(
129            format,
130            "json" | "application/json" | "application/fhir+json" | "application/fhir json"
131        )
132    {
133        return Err(OperationOutcomeError::error(
134            IssueType::not_supported(),
135            format!("_format '{format}' is not supported; only JSON is served."),
136        ));
137    }
138    Ok(pretty.as_deref() == Some("true"))
139}
140
141async fn pretty_print(response: Response) -> Result<Response, OperationOutcomeError> {
142    let (mut parts, body) = response.into_parts();
143    let bytes = to_bytes(body, usize::MAX)
144        .await
145        .map_err(|e| OperationOutcomeError::error(IssueType::exception(), e.to_string()))?;
146    // Not JSON (or unreadable): leave the body as it was.
147    let bytes = serde_json::from_slice::<serde_json::Value>(&bytes)
148        .and_then(|value| serde_json::to_vec_pretty(&value))
149        .map_or(bytes, Bytes::from);
150    parts.headers.remove(axum::http::header::CONTENT_LENGTH);
151    Ok(Response::from_parts(parts, Body::from(bytes)))
152}
153
154async fn fhir_handler<
155    Repo: Repository + Send + Sync + 'static,
156    Search: SearchEngine + Send + Sync + 'static,
157    Terminology: FHIRTerminology + Send + Sync + 'static,
158>(
159    method: Method,
160    Extension(user): Extension<Arc<User>>,
161    uri: Uri,
162    OriginalUri(original_uri): OriginalUri,
163    Path(path): Path<ProjectPath>,
164    State(state): State<Arc<ServerState<Repo, Search, Terminology>>>,
165    headers: HeaderMap,
166    body: Bytes,
167) -> Result<Response, OperationOutcomeError> {
168    // Nested under the FHIR root, so the request URI is what follows it: the
169    // FHIR location, optionally preceded by the version.
170    let (requested_version, fhir_location) = split_fhir_version(uri.path());
171    let fhir_version = request_fhir_version(&user, &path, requested_version)?;
172
173    let ctx = Arc::new(
174        ServerCTX::new(
175            path.tenant,
176            path.project,
177            fhir_version,
178            user,
179            state.fhir_client.clone(),
180            state.rate_limit.clone(),
181        )
182        .with_tracing_id(Some(format!("rest-{}", generate_id(Some(8)))))
183        .with_prefer_async(jobs::prefers_async(&headers)),
184    );
185
186    let query = original_uri.query().unwrap_or_default();
187    let http_req = HTTPRequest::new(
188        method,
189        fhir_location.to_string(),
190        HTTPBody::Bytes(body),
191        query,
192    );
193    let fhir_request =
194        http_request_to_fhir_request(SupportedFHIRVersions::R4, http_req, &ctx).await?;
195
196    // Searches only: `_format` is also an operation parameter.
197    let pretty = matches!(fhir_request, FHIRRequest::Search(_)) && pretty_requested(query)?;
198
199    let response = state.fhir_client.request(ctx, fhir_request).await?;
200
201    let response = response.into_response();
202    if pretty {
203        pretty_print(response).await
204    } else {
205        Ok(response)
206    }
207}
208
209async fn public_metadata_handler<
210    Repo: Repository + Send + Sync + 'static,
211    Search: SearchEngine + Send + Sync + 'static,
212    Terminology: FHIRTerminology + Send + Sync + 'static,
213>(
214    Path(path): Path<ProjectPath>,
215    State(state): State<Arc<ServerState<Repo, Search, Terminology>>>,
216) -> Result<Response, OperationOutcomeError> {
217    let ctx = Arc::new(ServerCTX::system(
218        path.tenant,
219        path.project,
220        state.fhir_client.clone(),
221        state.rate_limit.clone(),
222    ));
223
224    state
225        .fhir_client
226        .capabilities(ctx)
227        .await
228        .map(|capabilities| FHIRResponse::Capabilities(FHIRCapabilitiesResponse { capabilities }))
229        .map(|fhir_response| fhir_response.into_response())
230}
231
232pub async fn server(
233    config: Arc<ServerConfig>,
234) -> Result<NormalizePath<Router>, OperationOutcomeError> {
235    let ip_source = match &config.monitoring.ip_source {
236        crate::config::IpSource::ConnectInfo => ClientIpSource::ConnectInfo,
237        crate::config::IpSource::CfConnectingIp => ClientIpSource::CfConnectingIp,
238        crate::config::IpSource::XRealIp => ClientIpSource::XRealIp,
239    };
240
241    get_certification_provider(config.as_ref());
242
243    let pool = get_pool(config.as_ref()).await;
244    let session_store = PostgresStore::new(pool.clone());
245    session_store.migrate().await.map_err(ConfigError::from)?;
246
247    let shared_state = create_services(config.clone()).await?;
248
249    // Everything under the FHIR root is a FHIR location. The version is a
250    // property of the project, so it is optional in the URL: `…/fhir/Patient/1`
251    // and `…/fhir/r4/Patient/1` are the same request, and the handler takes the
252    // version off the front of the location. Keeping that out of the route
253    // table means the two cannot go out of step.
254    //
255    // Two routes only because a wildcard does not match an empty segment, so it
256    // cannot match the FHIR root on its own.
257    let fhir_router = Router::new()
258        .route("/", any(fhir_handler))
259        .route("/{*fhir_location}", any(fhir_handler));
260
261    let protected_resources_router = Router::new()
262        .nest("/fhir", fhir_router)
263        .route("/mcp", post(mcp::route::mcp_handler))
264        // The status URL of an asynchronous job: polled, or deleted to cancel.
265        .route(
266            "/jobs/{id}",
267            get(jobs::status_handler).delete(jobs::cancel_handler),
268        )
269        .layer(
270            ServiceBuilder::new()
271                .layer(axum::middleware::from_fn_with_state(
272                    shared_state.clone(),
273                    auth_n::middleware::basic_auth::basic_auth_middleware,
274                ))
275                .layer(axum::middleware::from_fn_with_state(
276                    shared_state.clone(),
277                    auth_n::middleware::jwt::token_verifcation,
278                ))
279                .layer(axum::middleware::from_fn(
280                    auth_n::middleware::project_access::project_access,
281                )),
282        );
283
284    // Per spec must be at root of the fhir server which is why
285    // /fhir/.well-known/smart-configuration is used as the route. Because this is
286    // publically available it is not under protected_resources_router and does not
287    // require authentication.
288    let mut smart_configuration_router = Router::new().route(
289        "/fhir/.well-known/smart-configuration",
290        get(auth_n::oidc::routes::discovery::smart_configuration),
291    );
292
293    for version in SupportedFHIRVersions::ALL {
294        smart_configuration_router = smart_configuration_router.route(
295            &format!("/fhir/{version}/.well-known/smart-configuration"),
296            get(auth_n::oidc::routes::discovery::smart_configuration),
297        );
298    }
299
300    let smart_configuration_router = smart_configuration_router.route_layer(
301        ServiceBuilder::new().layer(axum::middleware::from_fn_with_state(
302            shared_state.clone(),
303            auth_n::oidc::middleware::project_exists,
304        )),
305    );
306
307    let mut project_router = Router::new()
308        .merge(protected_resources_router)
309        .merge(smart_configuration_router)
310        .nest(
311            "/oidc",
312            auth_n::oidc::routes::create_router(shared_state.clone()),
313        );
314
315    if config.security.publicize_fhir_metadata {
316        project_router = project_router.route("/fhir/metadata", get(public_metadata_handler));
317
318        for version in SupportedFHIRVersions::ALL {
319            project_router = project_router.route(
320                &format!("/fhir/{version}/metadata"),
321                get(public_metadata_handler),
322            );
323        }
324    }
325
326    let tenant_router = Router::new()
327        .route("/branding/logo", get(auth_n::tenant::routes::logo))
328        .nest("/auth", auth_n::tenant::routes::create_router())
329        // OpenID Connect Discovery 1.0 §4: the document lives at `[iss]/.well-known/openid-configuration`.
330        .route(
331            "/{project}/.well-known/openid-configuration",
332            get(auth_n::oidc::routes::discovery::openid_configuration),
333        )
334        .nest("/{project}/api/v1", project_router)
335        .nest(
336            "/mfa",
337            auth_n::mfa::routes::create_router(shared_state.clone()),
338        )
339        .layer(
340            // Relies on tenant (and now tenant branding) for html so moving operation outcome error handling to here.
341            ServiceBuilder::new()
342                .layer(axum::middleware::from_fn_with_state(
343                    shared_state.clone(),
344                    operation_outcome_error_handle,
345                ))
346                .layer(from_fn(log_operationoutcome_errors)),
347        );
348
349    let discovery_2_0_document_router = Router::new()
350        .route(
351            "/openid-configuration/w/{tenant}/{project}/{*resource}",
352            get(auth_n::oidc::routes::discovery::openid_configuration),
353        )
354        .route(
355            "/openid-configuration/w/{tenant}/{project}",
356            get(auth_n::oidc::routes::discovery::openid_configuration),
357        )
358        .route(
359            "/oauth-protected-resource/w/{tenant}/{project}/{*resource}",
360            get(auth_n::oidc::routes::discovery::oauth_protected_resource),
361        );
362
363    let app = Router::new()
364        .nest("/.well-known", discovery_2_0_document_router)
365        .nest(
366            "/auth",
367            auth_n::global::routes::create_router(shared_state.clone()),
368        )
369        .route("/openapi.json", get(openapi::openapi_document_handler))
370        .route(
371            "/schemas/fhir/{resource_type}",
372            get(openapi::resource_schema_handler),
373        )
374        .nest("/w/{tenant}", tenant_router)
375        .layer(
376            ServiceBuilder::new()
377                // Every request gets an id (a client's own is kept) that the
378                // error log and the response carry, so a support report can
379                // be matched to the log line.
380                .layer(SetRequestIdLayer::new(REQUEST_ID_HEADER, MakeRequestUuid))
381                .layer(PropagateRequestIdLayer::new(REQUEST_ID_HEADER))
382                .layer(CatchPanicLayer::new())
383                .layer(ip_source.into_extension())
384                .layer(NewSentryLayer::<Request<Body>>::new_from_top())
385                .layer(TraceLayer::new_for_http())
386                // 4mb by default.
387                .layer(DefaultBodyLimit::max(config.max_request_body_size))
388                // HTML pages carry the session's CSRF token next to text the
389                // caller chose (the query string echoed into form actions,
390                // the entered email). Compressing the two together lets the
391                // token be recovered byte by byte from response sizes
392                // (BREACH), so pages are sent uncompressed; JSON and FHIR
393                // responses, which is where size matters, still are.
394                .layer(CompressionLayer::new().compress_when(
395                    DefaultPredicate::new().and(NotForContentType::new("text/html")),
396                ))
397                .layer(security_headers(config.signup.turnstile.is_some()))
398                .layer(SetResponseHeaderLayer::overriding(
399                    HeaderName::from_static("x-api-version"),
400                    HeaderValue::from_static(SERVER_VERSION),
401                ))
402                .layer(
403                    SessionManagerLayer::new(session_store)
404                        .with_secure(true)
405                        .with_same_site(SameSite::None)
406                        .with_expiry(Expiry::OnInactivity(Duration::days(3))),
407                )
408                .layer(
409                    CorsLayer::new()
410                        .allow_methods(Any)
411                        .allow_origin(Any)
412                        .allow_headers(AllowHeaders::mirror_request())
413                        // Asynchronous from browser need these headers.
414                        .expose_headers([
415                            header::CONTENT_LOCATION,
416                            header::ETAG,
417                            header::RETRY_AFTER,
418                            HeaderName::from_static("x-progress"),
419                        ])
420                        // Set the max age for CORS preflight requests to 2 hours.
421                        .max_age(std::time::Duration::from_secs(7200)),
422                ),
423        )
424        .with_state(shared_state)
425        .nest(root_asset_route().to_str().unwrap(), create_static_server());
426
427    Ok(NormalizePathLayer::trim_trailing_slash().layer(app))
428}
429
430pub async fn serve(config: Arc<ServerConfig>, port: u16) -> Result<(), OperationOutcomeError> {
431    let server = server(config).await?;
432
433    let addr = format!("0.0.0.0:{}", port);
434    let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
435
436    tracing::info!("Server started");
437    axum::serve(
438        listener,
439        <tower_http::normalize_path::NormalizePath<Router> as ServiceExt<
440            axum::http::Request<Body>,
441        >>::into_make_service_with_connect_info::<SocketAddr>(server),
442    )
443    .await
444    .unwrap();
445
446    Ok(())
447}
448
449#[cfg(test)]
450mod tests {
451    use super::*;
452    use axum::body::{Body, to_bytes};
453    use axum::http::{Request, StatusCode};
454    use tower::ServiceExt;
455
456    #[test]
457    fn format_and_pretty_parameters() {
458        for format in [
459            "json",
460            "application/json",
461            "application/fhir%2Bjson",
462            "application/fhir+json",
463        ] {
464            assert!(!pretty_requested(&format!("_format={format}")).unwrap());
465        }
466        assert!(pretty_requested("name=x&_pretty=true").unwrap());
467        assert!(!pretty_requested("_pretty=false").unwrap());
468        assert!(!pretty_requested("_pretty=true&_pretty=false").unwrap());
469        assert!(pretty_requested("_format=xml").is_err());
470    }
471
472    #[test]
473    fn version_is_split_off_when_the_url_carries_one() {
474        assert_eq!(
475            split_fhir_version("/r4/Patient/123"),
476            (Some(SupportedFHIRVersions::R4), "Patient/123")
477        );
478        assert_eq!(
479            split_fhir_version("/r4"),
480            (Some(SupportedFHIRVersions::R4), "")
481        );
482    }
483
484    #[test]
485    fn a_location_is_not_mistaken_for_a_version() {
486        assert_eq!(split_fhir_version("/Patient/123"), (None, "Patient/123"));
487        assert_eq!(split_fhir_version("/"), (None, ""));
488        // An unsupported version reads as a resource type, and fails as one.
489        assert_eq!(split_fhir_version("/r4b/Patient"), (None, "r4b/Patient"));
490    }
491
492    /// Mirrors the routes `server()` builds, to check which handler each URL
493    /// shape reaches. The FHIR routes answer with and without the version, and
494    /// the public routes under the same prefix have to keep matching ahead of
495    /// the FHIR wildcard.
496    fn router() -> Router {
497        // Echoes what `fhir_handler` derives, so the tests check the location
498        // it would actually work on and not just which route matched.
499        async fn fhir(uri: Uri) -> String {
500            let (version, location) = split_fhir_version(uri.path());
501            format!(
502                "fhir version={} location={location}",
503                version.map_or("-".to_string(), |version| version.to_string())
504            )
505        }
506
507        let fhir_router = Router::new()
508            .route("/", any(fhir))
509            .route("/{*fhir_location}", any(fhir));
510
511        // Nested and merged exactly as `server()` does it, because how much of
512        // the path is left for the handler to read depends on that nesting.
513        let protected_resources_router = Router::new().nest("/fhir", fhir_router);
514
515        let mut project_router = Router::new()
516            .merge(protected_resources_router)
517            .route(
518                "/fhir/.well-known/smart-configuration",
519                get(|| async { "smart" }),
520            )
521            .route("/fhir/metadata", get(|| async { "metadata" }));
522
523        for version in SupportedFHIRVersions::ALL {
524            project_router = project_router
525                .route(
526                    &format!("/fhir/{version}/.well-known/smart-configuration"),
527                    get(|| async { "smart" }),
528                )
529                .route(
530                    &format!("/fhir/{version}/metadata"),
531                    get(|| async { "metadata" }),
532                );
533        }
534
535        let tenant_router = Router::new().nest("/{project}/api/v1", project_router);
536
537        Router::new().nest("/w/{tenant}", tenant_router)
538    }
539
540    async fn route_to(uri: &str) -> String {
541        let response = router()
542            .oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap())
543            .await
544            .unwrap();
545
546        assert_eq!(response.status(), StatusCode::OK, "{uri}");
547
548        String::from_utf8(
549            to_bytes(response.into_body(), usize::MAX)
550                .await
551                .unwrap()
552                .to_vec(),
553        )
554        .unwrap()
555    }
556
557    /// Both URL shapes reach the handler and resolve to the same FHIR
558    /// location, which also pins that nesting strips the prefix before the
559    /// handler reads the URI.
560    #[tokio::test]
561    async fn both_url_shapes_address_the_same_location() {
562        let base = "/w/acme/default/api/v1/fhir";
563
564        for (uri, expected) in [
565            (base.to_string(), "fhir version=- location="),
566            (format!("{base}/Patient"), "fhir version=- location=Patient"),
567            (
568                format!("{base}/Patient/123/_history/1"),
569                "fhir version=- location=Patient/123/_history/1",
570            ),
571            (format!("{base}/r4"), "fhir version=r4 location="),
572            (
573                format!("{base}/r4/Patient"),
574                "fhir version=r4 location=Patient",
575            ),
576            (
577                format!("{base}/r4/Patient/123/_history/1"),
578                "fhir version=r4 location=Patient/123/_history/1",
579            ),
580        ] {
581            assert_eq!(route_to(&uri).await, expected, "{uri}");
582        }
583    }
584
585    #[tokio::test]
586    async fn public_routes_keep_matching_ahead_of_the_fhir_routes() {
587        let base = "/w/acme/default/api/v1/fhir";
588
589        for (uri, expected) in [
590            (format!("{base}/metadata"), "metadata"),
591            (format!("{base}/r4/metadata"), "metadata"),
592            (format!("{base}/.well-known/smart-configuration"), "smart"),
593            (
594                format!("{base}/r4/.well-known/smart-configuration"),
595                "smart",
596            ),
597        ] {
598            assert_eq!(route_to(&uri).await, expected, "{uri}");
599        }
600    }
601}