fundaia 0.36.2

Command line for the Fundaia deployment platform: projects, services, variables, deployments, logs and metrics
//! Projects, the services in them and where those sit on the canvas.

use super::*;

impl ApiClient {
    pub async fn projects(&self) -> Result<Vec<Project>> {
        Ok(self
            .get::<ProjectsPage>("/api/infra/projects")
            .await?
            .projects)
    }

    /// Finds a project by id, slug or name. See `one_named`.
    pub async fn find_project(&self, reference: &str) -> Result<Project> {
        one_named(self.projects().await?, reference, "projects", || {
            format!("there is no project called `{reference}`")
        })
    }

    /// A project and one of its services, by the names a person typed.
    pub async fn locate(
        &self,
        project_reference: &str,
        service_reference: &str,
    ) -> Result<(Project, Service)> {
        let project = self.find_project(project_reference).await?;
        let service = self.find_service(&project.id, service_reference).await?;
        Ok((project, service))
    }

    pub async fn services(&self, project_id: &str) -> Result<ServicesPage> {
        self.get(&format!("/api/infra/projects/{project_id}/services"))
            .await
    }

    pub async fn canvas(&self, project_id: &str) -> Result<Canvas> {
        self.get(&format!("/api/infra/projects/{project_id}/canvas"))
            .await
    }

    /// Several nodes in one write, and the canvas afterwards.
    ///
    /// A PATCH per service would be the obvious shape and is the wrong one: the
    /// server writes a whole request inside one transaction, so a half-applied
    /// arrangement cannot exist — and the answer saves reading the canvas back.
    pub async fn place_on_canvas(
        &self,
        project_id: &str,
        positions: &[CanvasPlacement],
    ) -> Result<Canvas> {
        self.patch_for(
            &format!("/api/infra/projects/{project_id}/canvas"),
            &serde_json::json!({ "positions": positions }),
        )
        .await
    }

    pub async fn arrange_canvas(&self, project_id: &str, layout: &str) -> Result<Canvas> {
        self.post(
            &format!("/api/infra/projects/{project_id}/canvas/arrange"),
            &serde_json::json!({ "layout": layout }),
        )
        .await
    }

    /// The same lookup for a service, within one project.
    pub async fn find_service(&self, project_id: &str, reference: &str) -> Result<Service> {
        service_named(self.services(project_id).await?.services, reference)
    }

    /// Several services of one project, in the order named, from one read of
    /// its list — and no read at all when none is named.
    pub async fn find_services(
        &self,
        project_id: &str,
        references: &[impl AsRef<str>],
    ) -> Result<Vec<Service>> {
        if references.is_empty() {
            return Ok(Vec::new());
        }

        let page = self.services(project_id).await?;
        references
            .iter()
            .map(|reference| service_named(&page.services, reference.as_ref()).cloned())
            .collect()
    }

    pub async fn create_service(&self, project_id: &str, draft: &NewService) -> Result<String> {
        #[derive(serde::Deserialize)]
        #[serde(rename_all = "camelCase")]
        struct Created {
            service_id: String,
        }

        Ok(self
            .post::<Created, _>(&format!("/api/infra/projects/{project_id}/services"), draft)
            .await?
            .service_id)
    }

    /// With no workspace named, the server puts it in the caller's own.
    pub async fn create_project(
        &self,
        name: &str,
        description: Option<&str>,
        workspace_id: Option<&str>,
    ) -> Result<Project> {
        let mut body = serde_json::Map::new();
        body.insert("name".into(), name.into());
        if let Some(description) = description {
            body.insert("description".into(), description.into());
        }
        if let Some(workspace_id) = workspace_id {
            body.insert("workspaceId".into(), workspace_id.into());
        }

        Ok(self
            .post::<ProjectCreated, _>("/api/infra/projects", &serde_json::Value::Object(body))
            .await?
            .project)
    }

    /// An absent field keeps what the project already has, which is why the
    /// description is optional rather than emptied by omission.
    pub async fn rename_project(
        &self,
        project_id: &str,
        name: Option<&str>,
        description: Option<&str>,
    ) -> Result<()> {
        let mut body = serde_json::Map::new();
        if let Some(name) = name {
            body.insert("name".into(), name.into());
        }
        if let Some(description) = description {
            body.insert("description".into(), description.into());
        }

        self.patch(
            &format!("/api/infra/projects/{project_id}"),
            &serde_json::Value::Object(body),
        )
        .await
    }

    /// The confirmation travels: the route refuses without it.
    ///
    /// Not merely the dialog's belt and braces — this endpoint used to archive,
    /// so a `fundaia` from before the change sends the same `DELETE` it always
    /// sent. Without the parameter the server turns it away instead of
    /// destroying a project on behalf of a binary that thinks it is archiving.
    pub async fn delete_project(&self, project_id: &str, confirmation: &str) -> Result<()> {
        self.delete(&format!(
            "/api/infra/projects/{project_id}?confirm={}",
            utf8_percent_encode(confirmation, NON_ALPHANUMERIC)
        ))
        .await
    }

    pub async fn move_project(&self, project_id: &str, workspace_id: &str) -> Result<()> {
        self.patch(
            &format!("/api/infra/projects/{project_id}/workspace"),
            &serde_json::json!({ "workspaceId": workspace_id }),
        )
        .await
    }

    /// What has been done to a project, newest first.
    pub async fn activity(&self, project_id: &str, limit: usize) -> Result<Vec<ActivityEntry>> {
        Ok(self
            .get::<ActivityPage>(&format!(
                "/api/infra/projects/{project_id}/activity?limit={limit}"
            ))
            .await?
            .activity)
    }
}