athena_rs 3.3.0

Database gateway API
Documentation
//! Reverse proxy for forwarding REST requests to target databases/services.
//!
//! Copies most headers, manages auth header transformations, and caches JSON responses.
use actix_web::http::Method as ActixMethod;
use actix_web::http::{StatusCode, header};
use actix_web::{HttpRequest, HttpResponse, Responder, web};
use moka::future::Cache;
use reqwest::header::{HeaderMap, HeaderName, HeaderValue as ReqwestHeaderValue};
use reqwest::{Client, Method};
use serde_json::{Value, json};
use std::str::Split;
use std::sync::Arc;
use tracing::info;
use web::Data;

use reqwest::header::{CONNECTION, CONTENT_ENCODING, CONTENT_LENGTH, TRANSFER_ENCODING};

use crate::AppState;
use crate::api::gateway::fetch::handle_fetch_data_route;

const TARGET_BASE_URL: &str = "https://db-suitsbooks-nl.xylex.cloud";

const HOST_DEXTER: &str = "db-dexter.xylex.cloud";
const TARGET_BASE_URL_DEXTER: &str = "https://athena.dexter.xylex.cloud";

/// Proxy an incoming request to the configured target URL and return the response.
pub async fn proxy_request(
    req: HttpRequest,
    body: web::Bytes,
    app_state: Data<AppState>,
) -> impl Responder {
    let client: &Client = &app_state.client;
    let cache: &Arc<Cache<String, Value>> = &app_state.cache;
    let full_url: reqwest::Url = req.full_url();
    let full_url_path: &str = full_url.path();
    let query_params: &str = full_url.query().unwrap_or_default();
    let path: String = full_url_path.replacen("/rest/v1", "", 1);

    info!(
        "ATHENA Router | Incoming request path={} has_query_params={}",
        full_url_path,
        !query_params.is_empty()
    );

    let has_athena_client: bool = req
        .headers()
        .get("X-Athena-Client")
        .and_then(|value| value.to_str().ok())
        .filter(|value| !value.is_empty())
        .is_some();

    let targets_fetch_routes: [&str; 2] = ["/gateway/data", "/gateway/fetch"];
    if has_athena_client
        && targets_fetch_routes
            .iter()
            .any(|route| full_url_path.starts_with(route))
    {
        let body_json: Option<web::Json<Value>> = if body.is_empty() {
            None
        } else {
            match serde_json::from_slice::<Value>(&body) {
                Ok(parsed) => Some(web::Json(parsed)),
                Err(err) => {
                    info!(
                        "ATHENA Router | Failed to parse JSON body for fetch route: {}",
                        err
                    );
                    return HttpResponse::BadRequest()
                        .json(json!({"error": "Invalid JSON body for fetch route"}));
                }
            }
        };

        return handle_fetch_data_route(req.clone(), body_json, app_state.clone()).await;
    }

    // optional apikey query param
    let mut query_params_without_apikey: String = String::new();
    let mut apikey: Option<&str> = None;
    for pair in req.query_string().split('&') {
        let mut split: Split<'_, char> = pair.split('=');
        if let (Some(key), Some(value)) = (split.next(), split.next()) {
            if key == "apikey" {
                apikey = Some(value);
            } else {
                if !query_params_without_apikey.is_empty() {
                    query_params_without_apikey.push('&');
                }
                query_params_without_apikey.push_str(pair);
            }
        }
    }

    let host: &str = req
        .headers()
        .get(header::HOST)
        .and_then(|value| value.to_str().ok())
        .unwrap_or_default();

    let target_url_repl: String = if host.contains(HOST_DEXTER) {
        format!("{}{}", TARGET_BASE_URL_DEXTER, path)
    } else {
        format!("{}{}", TARGET_BASE_URL, path)
    };

    let mut target_url: String = target_url_repl;
    // inject query params without apikey
    if !query_params_without_apikey.is_empty() {
        target_url.push('?');
        target_url.push_str(&query_params_without_apikey);
    }

    info!("ATHENA Router | Routing to {}", target_url);

    // if it contains 'favicon.ico' return a 404
    if target_url.contains("favicon.ico") {
        return HttpResponse::NotFound().finish();
    }

    // Extract the JWT token and remove the "Bearer " prefix
    let mut jwt_token: String = req
        .headers()
        .get(header::AUTHORIZATION)
        .and_then(|value| value.to_str().ok())
        .and_then(|value| value.strip_prefix("Bearer "))
        .map(|value| value.to_string())
        .unwrap_or_default();

    // if apikey is provided, use it to override the jwt token
    if let Some(apikey) = apikey {
        jwt_token = apikey.to_string();
    }

    info!(
        "ATHENA Router | Auth token source={} token_present={}",
        if apikey.is_some() {
            "query_apikey"
        } else {
            "authorization_header"
        },
        !jwt_token.is_empty()
    );

    let cache_control_header: Option<header::HeaderValue> =
        req.headers().get(header::CACHE_CONTROL).cloned();

    let cachekey: String = format!("{}-{}-{}", req.method(), full_url, jwt_token)
        .replace('*', "_xXx_")
        .replace(' ', "_")
        .replace(':', "-")
        .replace('/', "_");

    if cache_control_header
        .as_ref()
        .is_none_or(|h| h != "no-cache")
        && let Some(cached_response) = cache.get(&cachekey).await
    {
        info!("Cache hit for key: {}", cachekey);
        return HttpResponse::Ok().json(cached_response);
    }

    let reqwest_method: Method = match *req.method() {
        ActixMethod::GET => Method::GET,
        ActixMethod::POST => Method::POST,
        ActixMethod::PUT => Method::PUT,
        ActixMethod::DELETE => Method::DELETE,
        ActixMethod::PATCH => Method::PATCH,
        _ => Method::GET,
    };

    let mut client_req: reqwest::RequestBuilder = client.request(reqwest_method, &target_url);
    for (key, value) in req.headers().iter() {
        if key != header::HOST {
            if let (Ok(reqwest_key), Ok(reqwest_value)) = (
                HeaderName::from_bytes(key.as_ref()),
                ReqwestHeaderValue::from_bytes(value.as_bytes()),
            ) {
                client_req = client_req.header(reqwest_key, reqwest_value);
            }
        }
    }

    // Set the apikey as the "apikey" header if it's not empty, otherwise use the JWT token
    if let Some(apikey) = apikey {
        client_req = client_req.header("Authorization", format!("Bearer {}", apikey));
    } else if !jwt_token.is_empty() {
        let token: String = if jwt_token.starts_with("Bearer ") {
            jwt_token.clone()
        } else {
            format!("Bearer {}", jwt_token)
        };
        client_req = client_req.header("apikey", token);
    }

    match client_req.body(body).send().await {
        Ok(res) => {
            let status_code: StatusCode =
                StatusCode::from_u16(res.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY);
            let headers: HeaderMap = res.headers().clone();
            let body_bytes: web::Bytes = res.bytes().await.unwrap_or_default();
            let json_body: Value = serde_json::from_slice(&body_bytes).unwrap_or(Value::Null);
            cache.insert(cachekey, json_body.clone()).await;
            let mut response: actix_web::HttpResponseBuilder = HttpResponse::build(status_code);

            for (key, value) in headers.iter() {
                if ![
                    CONTENT_ENCODING,
                    CONTENT_LENGTH,
                    TRANSFER_ENCODING,
                    CONNECTION,
                ]
                .contains(key)
                {
                    let actix_key: actix_web::http::header::HeaderName =
                        match actix_web::http::header::HeaderName::from_bytes(
                            key.as_str().as_bytes(),
                        ) {
                            Ok(parsed) => parsed,
                            Err(_) => continue,
                        };
                    let actix_value: actix_web::http::header::HeaderValue =
                        match actix_web::http::header::HeaderValue::from_bytes(value.as_bytes()) {
                            Ok(parsed) => parsed,
                            Err(_) => continue,
                        };

                    if actix_key == header::CONTENT_TYPE {
                        response.append_header((
                            actix_key,
                            header::HeaderValue::from_static("application/json"),
                        ));
                    } else {
                        response.append_header((actix_key, actix_value));
                    }
                }
            }
            response.body(body_bytes)
        }
        Err(e) => {
            info!(
                "\x1b[38;5;208mATHENA Router | Error sending request to target URL: {}\x1b[0m",
                e
            );
            HttpResponse::InternalServerError().finish()
        }
    }
}