fundaia 0.52.1

Command line for the Fundaia deployment platform: projects, services, variables, deployments, logs and metrics
//! The reads built for agents: attention, health, diagnosis, audits, search, probes.

use super::*;

/// What `grep` narrows by, all optional but the text.
pub struct LogSearch<'a> {
    pub text: &'a str,
    pub service_environment_id: Option<&'a str>,
    pub level: Option<&'a str>,
    pub stream: Option<&'a str>,
    pub hours: Option<u32>,
    pub limit: Option<u32>,
}

impl ApiClient {
    /// With `--env`, that environment in every project that has one.
    pub async fn attention(&self) -> Result<Answer<AttentionPage>> {
        self.get_answer(&format!(
            "/api/infra/attention{}",
            self.environment_query('?')
        ))
        .await
    }

    pub async fn project_health(&self, project_id: &str) -> Result<Answer<HealthPage>> {
        self.get_answer(&format!(
            "/api/infra/projects/{project_id}/health{}",
            self.environment_query('?')
        ))
        .await
    }

    pub async fn diagnosis(&self, service_environment_id: &str) -> Result<Answer<DiagnosisPage>> {
        self.get_answer(&format!(
            "/api/infra/services/{service_environment_id}/diagnosis"
        ))
        .await
    }

    pub async fn variable_audit(&self, project_id: &str) -> Result<Answer<VariableAuditPage>> {
        self.get_answer(&format!(
            "/api/infra/projects/{project_id}/variables/audit{}",
            self.environment_query('?')
        ))
        .await
    }

    pub async fn search_logs(
        &self,
        project_id: &str,
        search: &LogSearch<'_>,
    ) -> Result<Answer<LogSearchPage>> {
        self.get_answer(&format!(
            "/api/infra/projects/{project_id}/logs/search{}{}",
            search_query(search),
            self.environment_query('&')
        ))
        .await
    }

    pub async fn dependencies(
        &self,
        service_environment_id: &str,
    ) -> Result<Answer<DependenciesPage>> {
        self.get_answer(&format!(
            "/api/infra/services/{service_environment_id}/dependencies"
        ))
        .await
    }

    pub async fn manifest(&self, project_id: &str) -> Result<Answer<ManifestPage>> {
        self.get_answer(&format!(
            "/api/infra/projects/{project_id}/manifest{}",
            self.environment_query('?')
        ))
        .await
    }

    pub async fn compare_deployments(
        &self,
        from_id: &str,
        to_id: &str,
    ) -> Result<Answer<ComparisonPage>> {
        self.get_answer(&format!("/api/infra/deployments/{from_id}/compare/{to_id}"))
            .await
    }

    pub async fn probe(
        &self,
        service_environment_id: &str,
        path: &str,
    ) -> Result<Answer<ProbePage>> {
        self.post_answer(
            &format!("/api/infra/services/{service_environment_id}/probe"),
            &serde_json::json!({ "path": path }),
        )
        .await
    }

    /// Unbounded like a stream: the server answers after the health check, which can outlast a minute.
    pub async fn wake(&self, service_environment_id: &str) -> Result<Answer<WokenPage>> {
        let path = format!("/api/infra/services/{service_environment_id}/wake");
        let response = self
            .unbounded_request(Method::POST, &path, Some(&serde_json::json!({})))
            .send()
            .await?;
        let text = Self::check(response).await?;
        let raw = serde_json::from_str(&text)
            .with_context(|| format!("could not read the answer from {path}"))?;
        decoded(raw, &path)
    }

    pub async fn workflow_stats(
        &self,
        project_id: &str,
        limit: u32,
    ) -> Result<Answer<WorkflowStatsPage>> {
        self.get_answer(&format!(
            "/api/infra/projects/{project_id}/workflows/stats?limit={limit}"
        ))
        .await
    }
}

fn search_query(search: &LogSearch<'_>) -> String {
    let mut query = format!("?q={}", utf8_percent_encode(search.text, NON_ALPHANUMERIC));
    let pairs = [
        ("service", search.service_environment_id.map(str::to_owned)),
        ("level", search.level.map(str::to_owned)),
        ("stream", search.stream.map(str::to_owned)),
        ("hours", search.hours.map(|hours| hours.to_string())),
        ("limit", search.limit.map(|limit| limit.to_string())),
    ];
    for (name, value) in pairs {
        if let Some(value) = value {
            query.push_str(&format!(
                "&{name}={}",
                utf8_percent_encode(&value, NON_ALPHANUMERIC)
            ));
        }
    }
    query
}

#[cfg(test)]
mod tests {
    use super::*;

    fn only_text(text: &str) -> LogSearch<'_> {
        LogSearch {
            text,
            service_environment_id: None,
            level: None,
            stream: None,
            hours: None,
            limit: None,
        }
    }

    #[test]
    fn it_should_escape_the_text_it_searches_for() {
        assert_eq!(search_query(&only_text("a b&c")), "?q=a%20b%26c".to_owned());
    }

    #[test]
    fn it_should_leave_out_every_filter_not_given() {
        assert_eq!(search_query(&only_text("boom")), "?q=boom");
    }

    #[test]
    fn it_should_send_the_filters_that_were_given() {
        let search = LogSearch {
            level: Some("error"),
            hours: Some(48),
            ..only_text("boom")
        };
        assert_eq!(search_query(&search), "?q=boom&level=error&hours=48");
    }
}