liminal-server 0.25.0

Standalone server for the liminal messaging bus
Documentation
//! The Lys HTTP routes the production service bindings read and ask
//! (ACCESS-003 R1), behind one seam.
//!
//! `lys-pass` at the pinned commit carries only the pass, batch-check and
//! draft routes; the budget, stop, start-command and usage routes the
//! services bind to are Lys identity-server routes on Lys main
//! (`budgets_api.rs`, `stop_api.rs`, `launch_api.rs`, `budgets_act.rs`).
//! [`LysHttp`] is the seam they go through and [`HttpLys`] is its real
//! implementation: one request, with the bearer the binding names, answered
//! by Lys's status and JSON body unchanged. Nothing is cached and nothing is
//! retried here; a transport failure is `lys_could_not_be_asked`, and a
//! refusal status is carried back with Lys's own body so the refusal keeps
//! its name.

use super::ServiceEffectError;

/// One Lys answer: the HTTP status and the JSON body exactly as Lys sent it
/// (`Null` for an empty body).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct LysAnswer {
    /// The HTTP status code.
    pub status: u16,
    /// The body as JSON, unchanged.
    pub body: serde_json::Value,
}

impl LysAnswer {
    /// Whether Lys answered with a success status.
    #[must_use]
    pub fn succeeded(&self) -> bool {
        (200..300).contains(&self.status)
    }

    /// Lys's own refusal name from the body (`refusal`, else `error`), or
    /// the status when the body names none.
    #[must_use]
    pub fn refusal(&self) -> String {
        self.body
            .get("refusal")
            .or_else(|| self.body.get("error"))
            .and_then(serde_json::Value::as_str)
            .map_or_else(|| format!("lys_status_{}", self.status), str::to_owned)
    }

    /// The answer as a typed effect error naming the route and Lys's refusal.
    #[must_use]
    pub fn refused(&self, route: &str) -> ServiceEffectError {
        ServiceEffectError(format!("Lys refused {route}: {}", self.refusal()))
    }
}

/// The Lys routes the services bind to, asked with a bearer pass.
pub trait LysHttp: Send + Sync + std::fmt::Debug {
    /// `GET {issuer}/{path}` with `bearer`.
    ///
    /// # Errors
    /// Lys could not be asked (`lys_could_not_be_asked`), or its body was not
    /// JSON.
    fn get(&self, bearer: &str, path: &str) -> Result<LysAnswer, ServiceEffectError>;

    /// `POST {issuer}/{path}` with `bearer` and the JSON `body`.
    ///
    /// # Errors
    /// Lys could not be asked (`lys_could_not_be_asked`), or its body was not
    /// JSON.
    fn post(
        &self,
        bearer: &str,
        path: &str,
        body: &serde_json::Value,
    ) -> Result<LysAnswer, ServiceEffectError>;
}

/// The error a transport failure is named by, as `lys-pass` names it.
fn could_not_be_asked(route: &str, error: reqwest::Error) -> ServiceEffectError {
    ServiceEffectError(format!(
        "lys_could_not_be_asked: {route}: {}",
        error.without_url()
    ))
}

/// [`LysHttp`] over `reqwest`.
///
/// Each request is run to completion on a scoped thread against the
/// server's tokio runtime, as the live ask is (`auth_lys::live`), so a
/// connection process never blocks the runtime's own workers and never nests
/// a runtime.
pub struct HttpLys {
    http: reqwest::Client,
    issuer: reqwest::Url,
    handle: tokio::runtime::Handle,
}

impl std::fmt::Debug for HttpLys {
    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        formatter
            .debug_struct("HttpLys")
            .field("issuer", &self.issuer.as_str())
            .finish_non_exhaustive()
    }
}

impl HttpLys {
    /// Asks `issuer`, an HTTP origin, through `handle`'s runtime.
    ///
    /// # Errors
    /// The issuer is not an HTTP origin.
    pub fn new(issuer: &str, handle: tokio::runtime::Handle) -> Result<Self, ServiceEffectError> {
        let mut issuer = reqwest::Url::parse(issuer)
            .map_err(|error| ServiceEffectError(format!("Lys issuer is not a URL: {error}")))?;
        if !matches!(issuer.scheme(), "http" | "https")
            || issuer.host_str().is_none()
            || !issuer.username().is_empty()
            || issuer.password().is_some()
            || issuer.query().is_some()
            || issuer.fragment().is_some()
        {
            return Err(ServiceEffectError(
                "Lys issuer is not an HTTP origin".to_owned(),
            ));
        }
        if !issuer.path().ends_with('/') {
            let path = format!("{}/", issuer.path());
            issuer.set_path(&path);
        }
        Ok(Self {
            http: reqwest::Client::new(),
            issuer,
            handle,
        })
    }

    fn endpoint(&self, path: &str) -> Result<reqwest::Url, ServiceEffectError> {
        self.issuer
            .join(path.trim_start_matches('/'))
            .map_err(|error| ServiceEffectError(format!("Lys route {path} refused: {error}")))
    }

    fn run(
        &self,
        route: &str,
        request: reqwest::RequestBuilder,
    ) -> Result<LysAnswer, ServiceEffectError> {
        let handle = &self.handle;
        let future = async move {
            let response = request
                .send()
                .await
                .map_err(|error| could_not_be_asked(route, error))?;
            let status = response.status().as_u16();
            let bytes = response
                .bytes()
                .await
                .map_err(|error| could_not_be_asked(route, error))?;
            let body = if bytes.is_empty() {
                serde_json::Value::Null
            } else {
                serde_json::from_slice(&bytes).map_err(|error| {
                    ServiceEffectError(format!(
                        "lys_could_not_be_asked: {route}: the answer is not JSON: {error}"
                    ))
                })?
            };
            Ok(LysAnswer { status, body })
        };
        std::thread::scope(|scope| scope.spawn(move || handle.block_on(future)).join())
            .unwrap_or_else(|_| {
                Err(ServiceEffectError(format!(
                    "lys_could_not_be_asked: {route}: the request did not complete"
                )))
            })
    }
}

impl LysHttp for HttpLys {
    fn get(&self, bearer: &str, path: &str) -> Result<LysAnswer, ServiceEffectError> {
        let url = self.endpoint(path)?;
        self.run(path, self.http.get(url).bearer_auth(bearer))
    }

    fn post(
        &self,
        bearer: &str,
        path: &str,
        body: &serde_json::Value,
    ) -> Result<LysAnswer, ServiceEffectError> {
        let url = self.endpoint(path)?;
        self.run(path, self.http.post(url).bearer_auth(bearer).json(body))
    }
}

/// One URL path segment, percent-encoded (`.` included, so `..` can never
/// climb), so a name can never reach another Lys route.
#[must_use]
pub fn segment(value: &str) -> String {
    const HEX: &[u8; 16] = b"0123456789ABCDEF";
    let mut encoded = String::with_capacity(value.len());
    for byte in value.bytes() {
        if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'~') {
            encoded.push(char::from(byte));
        } else {
            encoded.push('%');
            for nibble in [byte >> 4, byte & 0x0f] {
                if let Some(digit) = HEX.get(usize::from(nibble)) {
                    encoded.push(char::from(*digit));
                }
            }
        }
    }
    encoded
}