liminal-server 0.25.4

Standalone server for the liminal messaging bus
Documentation
//! The production [`BudgetSource`]: budgets and stops read from Lys, never
//! decided in liminal (ACCESS-003 R1).
//!
//! `budgets.<name>` names an agent, the seat of that name. Its budget is
//! Lys's `GET /budgets/agent/{name}` answer and its stops Lys's
//! `GET /agents/{name}/stops` answer, both carried whole in a
//! [`BudgetPolicy`]. Lys answers a budget never set with version 0; with no
//! budget set and no stop kept, Lys holds no policy for the seat, which is
//! `Ok(None)` and so a refusal upstream, never a local default. Lys reads
//! budgets for an administrator or the person responsible for the agent
//! only, so the reads are made with this liminal's own Lys pass and a Lys
//! refusal comes back by its name.

use super::lys_http::{LysHttp, segment};
use super::{BudgetPolicy, BudgetSource, ServiceEffectError};
use std::sync::Arc;

/// Budgets and stops read from Lys over [`LysHttp`].
pub struct LysBudgets {
    http: Arc<dyn LysHttp>,
    bearer: String,
}

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

impl LysBudgets {
    /// Reads through `http` with `bearer`, this liminal's own Lys pass. The
    /// pass is never shown by `Debug` (ACCESS-004 R5).
    #[must_use]
    pub const fn new(http: Arc<dyn LysHttp>, bearer: String) -> Self {
        Self { http, bearer }
    }
}

/// Whether Lys's budget answer holds a budget: a version above 0.
fn budget_set(budgets: &serde_json::Value) -> Result<bool, ServiceEffectError> {
    budgets
        .get("version")
        .and_then(serde_json::Value::as_u64)
        .map(|version| version > 0)
        .ok_or_else(|| {
            ServiceEffectError(
                "lys_could_not_be_asked: the budget answer carries no version".to_owned(),
            )
        })
}

/// Whether Lys's stops answer keeps any stop.
fn stops_kept(stops: &serde_json::Value) -> Result<bool, ServiceEffectError> {
    stops
        .get("stops")
        .and_then(serde_json::Value::as_array)
        .map(|kept| !kept.is_empty())
        .ok_or_else(|| {
            ServiceEffectError(
                "lys_could_not_be_asked: the stops answer carries no stops".to_owned(),
            )
        })
}

impl BudgetSource for LysBudgets {
    fn read(&self, name: &str) -> Result<Option<BudgetPolicy>, ServiceEffectError> {
        let id = segment(name);
        let budget_route = format!("budgets/agent/{id}");
        let budgets = self.http.get(&self.bearer, &budget_route)?;
        if !budgets.succeeded() {
            return Err(budgets.refused(&budget_route));
        }
        let stop_route = format!("agents/{id}/stops");
        let stops = self.http.get(&self.bearer, &stop_route)?;
        if !stops.succeeded() {
            return Err(stops.refused(&stop_route));
        }
        if !budget_set(&budgets.body)? && !stops_kept(&stops.body)? {
            return Ok(None);
        }
        Ok(Some(BudgetPolicy {
            holder: format!("agent:{name}"),
            budgets: budgets.body,
            stops: stops.body,
        }))
    }
}