sqlpage 0.45.0

Build data user interfaces entirely in SQL. A web server that takes .sql files and formats the query result using pre-made configurable professional-looking components.
use std::{fmt::Write, str::FromStr};

use anyhow::{Context, anyhow};
use opentelemetry_semantic_conventions::attribute as otel;
use tracing::Instrument;

use crate::webserver::{
    database::sqlpage_functions::http_fetch_request::HttpFetchRequest,
    http_client::make_http_client,
    http_request_info::RequestInfo,
};

pub(super) fn build_request<'a>(
    client: &'a awc::Client,
    http_request: &'a HttpFetchRequest<'_>,
) -> anyhow::Result<awc::ClientRequest> {
    use awc::http::Method;
    let method = if let Some(method) = &http_request.method {
        Method::from_str(method).with_context(|| format!("Invalid HTTP method: {method}"))?
    } else {
        Method::GET
    };
    let mut req = client.request(method, http_request.url.as_ref());
    if let Some(timeout) = http_request.timeout_ms {
        req = req.timeout(core::time::Duration::from_millis(timeout));
    }
    for (k, v) in &http_request.headers {
        req = req.insert_header((k.as_ref(), v.as_ref()));
    }
    if let Some(username) = &http_request.username {
        let password = http_request.password.as_deref().unwrap_or_default();
        req = req.basic_auth(username, password);
    }
    Ok(req)
}

pub(super) fn prepare_request_body(
    body: &serde_json::value::RawValue,
    mut req: awc::ClientRequest,
) -> anyhow::Result<(String, awc::ClientRequest)> {
    let val = body.get();
    let body_str = if val.starts_with('"') {
        serde_json::from_str::<'_, String>(val).with_context(|| {
            format!("Invalid JSON string in the body of the HTTP request: {val}")
        })?
    } else {
        req = req.content_type("application/json");
        val.to_owned()
    };
    Ok((body_str, req))
}

pub(super) fn fetch_span(http_request: &HttpFetchRequest<'_>) -> tracing::Span {
    let method = http_request.method.as_deref().unwrap_or("GET");
    tracing::info_span!(
        "http.client",
        "otel.name" = format!("{method}"),
        { otel::HTTP_REQUEST_METHOD } = method,
        { otel::URL_FULL } = %http_request.url,
        { otel::HTTP_REQUEST_BODY_SIZE } = tracing::field::Empty,
        { otel::HTTP_RESPONSE_STATUS_CODE } = tracing::field::Empty,
    )
}

pub(super) fn send_request(
    request: &RequestInfo,
    http_request: &HttpFetchRequest<'_>,
) -> anyhow::Result<awc::SendClientRequest> {
    let client = make_http_client(&request.app_state.config)
        .with_context(|| "Unable to create an HTTP client")?;
    let req = build_request(&client, http_request)?;

    log::info!("Fetching {}", http_request.url);
    if let Some(body) = &http_request.body {
        let (body, req) = prepare_request_body(body, req)?;
        tracing::Span::current().record(
            otel::HTTP_REQUEST_BODY_SIZE,
            i64::try_from(body.len()).unwrap_or(i64::MAX),
        );
        Ok(req.send_body(body))
    } else {
        Ok(req.send())
    }
}

pub(super) async fn fetch(
    request: &RequestInfo,
    http_request: Option<HttpFetchRequest<'_>>,
) -> anyhow::Result<Option<String>> {
    let Some(http_request) = http_request else {
        return Ok(None);
    };
    let fetch_span = fetch_span(&http_request);

    async {
        let response_result = send_request(request, &http_request)?.await;
        let mut response = response_result
            .map_err(|e| anyhow!("Unable to fetch {}: {e}", http_request.url))?;

        tracing::Span::current().record(
            otel::HTTP_RESPONSE_STATUS_CODE,
            i64::from(response.status().as_u16()),
        );

        log::debug!(
            "Finished fetching {}. Status: {}",
            http_request.url,
            response.status()
        );
        log::debug!(
            "Fetch response headers for {}: content_type={:?}",
            http_request.url,
            response
                .headers()
                .get("content-type")
                .and_then(|value| value.to_str().ok())
        );

        let body = response
            .body()
            .await
            .with_context(|| {
                format!(
                    "Unable to read the body of the response from {}",
                    http_request.url
                )
            })?
            .to_vec();
        log::debug!(
            "Fetched {} response body: body_len={} bytes, encoding={:?}",
            http_request.url,
            body.len(),
            http_request.response_encoding
        );
        let response_str = decode_response(body, http_request.response_encoding.as_deref())?;
        Ok(Some(response_str))
    }
    .instrument(fetch_span)
    .await
}

pub(super) fn decode_response(response: Vec<u8>, encoding: Option<&str>) -> anyhow::Result<String> {
    match encoding {
        Some("base64") => Ok(base64::Engine::encode(
            &base64::engine::general_purpose::STANDARD,
            response,
        )),
        Some("base64url") => Ok(base64::Engine::encode(
            &base64::engine::general_purpose::URL_SAFE,
            response,
        )),
        Some("hex") => Ok(response.into_iter().fold(String::new(), |mut acc, byte| {
            write!(&mut acc, "{byte:02x}").unwrap();
            acc
        })),
        Some(encoding_label) => Ok(encoding_rs::Encoding::for_label(encoding_label.as_bytes())
            .with_context(|| format!("Invalid encoding name: {encoding_label}"))?
            .decode(&response)
            .0
            .into_owned()),
        None => {
            let body_str = String::from_utf8(response);
            match body_str {
                Ok(body_str) => Ok(body_str),
                Err(decoding_error) => {
                    log::warn!(
                        "fetch(...) response is not UTF-8 and no encoding was specified. Decoding the response as base64. Please explicitly set the encoding to \"base64\" if this is the expected behavior."
                    );
                    Ok(base64::Engine::encode(
                        &base64::engine::general_purpose::STANDARD,
                        decoding_error.into_bytes(),
                    ))
                }
            }
        }
    }
}