shared-framework 0.0.17

Reusable building blocks for HTTP services — Hyper routing, SeaORM data layer, validation, OpenAPI docs, jobs, queues, cache.
Documentation
//! Basilisk gateway registration and service-bus connection.
//!
//! [`GatewayConnect`] registers this service's path prefixes with the Basilisk
//! gateway and holds the connected [`BasiliskClient`](BasiliskClient).
//! [`GatewayConnect::set_up`] is the usual entry point (called by
//! [`GenericStartup`](crate::app::GenericStartup) during bootstrap);
//! [`GatewayConnect::instance`] returns the process-wide connection once established.
//!
//! ```ignore
//! let gateway = GatewayConnect::set_up(
//!     vec!["/api".to_string()],
//!     "http".to_string(),
//!     1,
//!     "token".to_string(),
//! ).await?;
//! ```

use std::sync::{Arc, OnceLock};
use std::time::Duration;

use basilisk_rust_client::{BasiliskClient, BasiliskClientConfig};

use crate::env::AppEnvironment;
use crate::retry::RetryStrategy;

const MAX_CONNECT_ATTEMPTS: usize = 5;

static INSTANCE: OnceLock<Arc<GatewayConnect>> = OnceLock::new();

/// Gateway connection handle. Wraps the connected Basilisk client, if any.
#[derive(Clone)]
pub struct GatewayConnect {
    /// The connected client. Always `Some` on instances returned by [`GatewayConnect::set_up`].
    pub client: Option<BasiliskClient>,
}

impl GatewayConnect {
    fn new(client: BasiliskClient) -> Self {
        Self { client: Some(client) }
    }

    /// Returns the process-wide instance if [`GatewayConnect::set_up`] has succeeded.
    pub fn instance() -> Option<Arc<Self>> {
        INSTANCE.get().cloned()
    }

    /// Returns the connected client, or `None` when there is no connection.
    pub fn get_client(&self) -> Option<&BasiliskClient> {
        self.client.as_ref()
    }

    /// Returns the connected client. Errors when there is no connection (e.g. setup was skipped for empty mount paths).
    pub fn require_client(&self) -> anyhow::Result<&BasiliskClient> {
        self.client.as_ref().ok_or_else(|| anyhow::anyhow!("gateway not connected — no BasiliskClient (empty mountPaths)"))
    }

    /// Registers with the gateway and connects the service bus with exponential-backoff retries.
    ///
    /// Reads `BASILISK_HOST`, `BASILISK_BUS_PORT`, `BASILISK_TOKEN`,
    /// `BASILISK_GATEWAY_URL`, `BASILISK_SERVICE_ID`, and `SERVICE_KEY` from
    /// [`AppEnvironment`](AppEnvironment) (falling back to the process
    /// environment), and `SERVER_PORT` for the service port (defaults to `8080`).
    /// `mount_paths` are registered as the service path prefixes; `scheme`,
    /// `weight`, and `auth_type` go into the registration. Returns `Ok(None)`
    /// without connecting when `mount_paths` is empty. Errors when already
    /// initialized, when required env is missing, or after 5 failed connect attempts.
    pub async fn set_up(
        mount_paths: Vec<String>,
        scheme: impl Into<String>,
        weight: i32,
        auth_type: impl Into<String>,
    ) -> anyhow::Result<Option<Arc<Self>>> {
        if INSTANCE.get().is_some() {
            anyhow::bail!("Already initialized");
        }
        if mount_paths.is_empty() {
            // Empty mount paths mean no registration.
            return Ok(None);
        }

        // Resolve env — prefer AppEnvironment if initialized, else std::env
        let (host, port, bus_port, token, base_url, service_id, fingerprint) =
            resolve_env()?;

        let config = BasiliskClientConfig {
            gateway_base_url: base_url,
            bus_host: host.clone(),
            bus_port,
            service_id: service_id.clone(),
            fingerprint,
            path_prefixes: mount_paths.clone(),
            scheme: scheme.into(),
            host,
            port,
            weight,
            registration_auth_type: auth_type.into(),
            registration_token: token,
        };

        let client = Self::connect_with_retry(config).await?;
        let instance = Arc::new(Self::new(client));
        let _ = INSTANCE.set(instance.clone());
        Ok(Some(instance))
    }

    /// Connects with an explicit client config and the same retry policy as [`GatewayConnect::set_up`].
    ///
    /// Errors when already initialised or after 5 failed connect attempts.
    pub async fn set_up_with_config(config: BasiliskClientConfig) -> anyhow::Result<Arc<Self>> {
        if INSTANCE.get().is_some() {
            anyhow::bail!("Already initialized");
        }
        let client = Self::connect_with_retry(config).await?;
        let instance = Arc::new(Self::new(client));
        let _ = INSTANCE.set(instance.clone());
        Ok(instance)
    }

    async fn connect_with_retry(config: BasiliskClientConfig) -> anyhow::Result<BasiliskClient> {
        let strategy = RetryStrategy::new()
            .with_base_delay(Duration::from_millis(1_000))
            .with_max_delay(Duration::from_millis(30_000))
            .with_max_attempts(MAX_CONNECT_ATTEMPTS);

        // RetryStrategy in this crate offers `retry` and `with_exponential_backoff`.
        // Use `retry` with a closure capturing config.
        let cfg = config.clone();
        // We need a clone per attempt, so use retry with closure that clones cfg.
        let result = strategy
            .retry(|| {
                let cfg = cfg.clone();
                async move { BasiliskClient::connect(cfg).await.map_err(|e| e.to_string()) }
            })
            .await;

        match result {
            Ok(client) => Ok(client),
            Err(e) => anyhow::bail!("basilisk connect exhausted after {} attempts: {}", e.attempts, e.source),
        }
    }

    /// Deregisters this service from the gateway. No-ops when there is no connected client.
    pub async fn deregister(&self) -> anyhow::Result<()> {
        if let Some(c) = self.client.as_ref() {
            c.deregister().await
        } else {
            Ok(())
        }
    }
}

fn resolve_env() -> anyhow::Result<(String, u16, u16, String, String, String, String)> {
    // Try AppEnvironment first, fallback to std::env
    let env = AppEnvironment::try_get();
    let get_required = |key: &str| -> anyhow::Result<String> {
        if let Some(e) = env {
            // AppEnvironment has typed getters, but we can use get_value or try to read directly
            // Fall back to std::env if not found in AppEnvironment's map.
            if let Some(v) = e.get_value(key) {
                return Ok(v);
            }
        }
        std::env::var(key).map_err(|_| anyhow::anyhow!("missing required env {}", key))
    };

    let host = get_required("BASILISK_HOST")?;
    let port: u16 = if let Some(e) = env {
        e.get_value("SERVER_PORT")
            .and_then(|v| v.parse().ok())
            .unwrap_or(8080)
    } else {
        std::env::var("SERVER_PORT")
            .ok()
            .and_then(|v| v.parse().ok())
            .unwrap_or(8080)
    };
    let bus_port: u16 = get_required("BASILISK_BUS_PORT")?
        .parse()
        .map_err(|_| anyhow::anyhow!("invalid BASILISK_BUS_PORT"))?;
    let token = get_required("BASILISK_TOKEN")?;
    let base_url = get_required("BASILISK_GATEWAY_URL")?;
    let service_id = get_required("BASILISK_SERVICE_ID")?;
    let fingerprint = get_required("SERVICE_KEY")?;

    Ok((host, port, bus_port, token, base_url, service_id, fingerprint))
}