From f8e36c0dab1c28d63abf2ee625017cfcbb52df39 Mon Sep 17 00:00:00 2001 From: tv Date: Mon, 2 Nov 2026 21:42:35 +0100 Subject: api: support serving under a path prefix --- src/api/mod.rs | 75 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 74 insertions(+), 1 deletion(-) (limited to 'src') diff --git a/src/api/mod.rs b/src/api/mod.rs index 9bda84a..35b1a06 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -77,13 +77,17 @@ pub(crate) fn router(state: AppState, auth_state: AuthState, debug_api: bool) -> api.layer(middleware::from_fn_with_state(auth_state, basic_auth)) // The hand-written spec (`docs/openapi.yaml`), and the Swagger UI showing it. .merge( - SwaggerUi::new("/swagger-ui").config(utoipa_swagger_ui::Config::from("/openapi.yaml")), + // Relative to the UI's page (`/swagger-ui/`), so it is found behind a proxy that + // serves the API under a path of its own too. + SwaggerUi::new("/swagger-ui") + .config(utoipa_swagger_ui::Config::from("../openapi.yaml")), ) .route("/openapi.yaml", get(openapi_spec)) .route("/health", get(health::node_health)) .route("/ready", get(health::ready)) // Outermost, so authentication failures get the error body too. .layer(middleware::from_fn(json_errors)) + .layer(middleware::from_fn(relative_redirects)) .with_state(state) } @@ -107,6 +111,7 @@ pub(crate) fn relay_only_router( .layer(middleware::from_fn_with_state(auth_state, basic_auth)) .route("/health", get(health::health)) .layer(middleware::from_fn(json_errors)) + .layer(middleware::from_fn(relative_redirects)) .with_state(relay) } @@ -131,6 +136,56 @@ pub(crate) fn error_response( (status, Json(body)).into_response() } +/// Redirects to a path of this API (`Location: /store/…`) made relative to the request's +/// (`3`, `swagger-ui/`): they then lead to the same place behind a reverse proxy +/// that serves the API under a path of its own (`/continuum/store/…`). +async fn relative_redirects(request: Request, next: Next) -> Response { + let from = request.uri().path().to_owned(); + let mut response = next.run(request).await; + let relative = response + .headers() + .get(axum::http::header::LOCATION) + .and_then(|location| location.to_str().ok()) + .filter(|location| location.starts_with('/') && !location.starts_with("//")) + .map(|location| relative_to(&from, location)); + if let Some(location) = relative.and_then(|location| HeaderValue::from_str(&location).ok()) { + response + .headers_mut() + .insert(axum::http::header::LOCATION, location); + } + response +} + +/// `to` (a path, with its query if any) as a reference relative to the page at `from` +/// (a path). +fn relative_to(from: &str, to: &str) -> String { + let (path, query) = match to.split_once('?') { + Some((path, query)) => (path, Some(query)), + None => (to, None), + }; + // The page's directory, and the target's segments (the last its name). + let from: Vec<&str> = from.split('/').collect(); + let directory = &from[..from.len() - 1]; + let target: Vec<&str> = path.split('/').collect(); + let common = directory + .iter() + .zip(&target[..target.len() - 1]) + .take_while(|(a, b)| a == b) + .count(); + let mut relative = "../".repeat(directory.len() - common); + relative.push_str(&target[common..].join("/")); + // Not empty, and not taken for a scheme (`a:b`). + let first = relative.split('/').next().unwrap_or(""); + if relative.is_empty() || first.contains(':') { + relative.insert_str(0, "./"); + } + if let Some(query) = query { + relative.push('?'); + relative.push_str(query); + } + relative +} + /// Gives every error answer the same body (`error_response`). Handlers that fail with /// a bare status or a text message, and axum's own rejections (a malformed query or /// body), get it here: the text becomes the message, or the status's reason if there @@ -236,3 +291,21 @@ pub(crate) fn store_status(error: StoreError, context: &str) -> ApiError { pub(crate) fn media_type(value: &str) -> &str { value.split(';').next().unwrap_or("").trim() } + +#[cfg(test)] +mod redirect_tests { + use super::relative_to; + + #[test] + fn redirects_are_relative_to_the_request() { + assert_eq!(relative_to("/store/queue/-1", "/store/queue/3"), "3"); + assert_eq!( + relative_to("/store/a/-1/b", "/store/a/0/b?x=1"), + "../0/b?x=1" + ); + assert_eq!(relative_to("/store/-1", "/store/2"), "2"); + assert_eq!(relative_to("/swagger-ui", "/swagger-ui/"), "swagger-ui/"); + assert_eq!(relative_to("/store/a/b", "/store/a/"), "./"); + assert_eq!(relative_to("/store/x", "/store/a:b"), "./a:b"); + } +} -- cgit v1.3.1