systemprompt-cloud 0.1.22

systemprompt.io Cloud infrastructure - API client, credentials, OAuth
Documentation
use std::time::Duration;

use anyhow::{Context, Result, anyhow};
use chrono::Utc;
use reqwest::{Client, StatusCode};
use serde::Serialize;
use serde::de::DeserializeOwned;
use systemprompt_models::modules::ApiPaths;

use super::types::{
    ActivityRequest, ApiError, CheckoutRequest, CheckoutResponse, ListResponse, Plan, Tenant,
    UserMeResponse,
};

#[derive(Debug)]
pub struct CloudApiClient {
    pub(super) client: Client,
    pub(super) api_url: String,
    pub(super) token: String,
}

impl CloudApiClient {
    pub fn new(api_url: &str, token: &str) -> Result<Self, reqwest::Error> {
        Ok(Self {
            client: Client::builder()
                .connect_timeout(Duration::from_secs(10))
                .timeout(Duration::from_secs(30))
                .build()?,
            api_url: api_url.to_string(),
            token: token.to_string(),
        })
    }

    #[must_use]
    pub fn api_url(&self) -> &str {
        &self.api_url
    }

    #[must_use]
    pub fn token(&self) -> &str {
        &self.token
    }

    pub(super) async fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T> {
        let url = format!("{}{}", self.api_url, path);
        let response = self
            .client
            .get(&url)
            .header("Authorization", format!("Bearer {}", self.token))
            .send()
            .await
            .context("Failed to connect to API")?;

        self.handle_response(response).await
    }

    pub(super) async fn post<T: DeserializeOwned, B: Serialize + Sync>(
        &self,
        path: &str,
        body: &B,
    ) -> Result<T> {
        let url = format!("{}{}", self.api_url, path);
        let response = self
            .client
            .post(&url)
            .header("Authorization", format!("Bearer {}", self.token))
            .json(body)
            .send()
            .await
            .context("Failed to connect to API")?;

        self.handle_response(response).await
    }

    pub(super) async fn post_no_response<B: Serialize + Sync>(
        &self,
        path: &str,
        body: &B,
    ) -> Result<()> {
        let url = format!("{}{}", self.api_url, path);
        let response = self
            .client
            .post(&url)
            .header("Authorization", format!("Bearer {}", self.token))
            .json(body)
            .send()
            .await
            .context("Failed to connect to API")?;

        let status = response.status();
        if status == StatusCode::UNAUTHORIZED {
            return Err(anyhow!(
                "Authentication failed. Please run 'systemprompt cloud login' again."
            ));
        }
        if status == StatusCode::NO_CONTENT || status.is_success() {
            return Ok(());
        }

        let error_text = response
            .text()
            .await
            .unwrap_or_else(|_| String::from("<failed to read response body>"));

        serde_json::from_str::<ApiError>(&error_text).map_or_else(
            |_| {
                Err(anyhow!(
                    "Request failed with status {}: {}",
                    status,
                    error_text.chars().take(500).collect::<String>()
                ))
            },
            |parsed| Err(anyhow!("{}: {}", parsed.error.code, parsed.error.message)),
        )
    }

    pub(super) async fn put<T: DeserializeOwned, B: Serialize + Sync>(
        &self,
        path: &str,
        body: &B,
    ) -> Result<T> {
        let url = format!("{}{}", self.api_url, path);
        let response = self
            .client
            .put(&url)
            .header("Authorization", format!("Bearer {}", self.token))
            .json(body)
            .send()
            .await
            .context("Failed to connect to API")?;

        self.handle_response(response).await
    }

    pub(super) async fn put_no_content<B: Serialize + Sync>(
        &self,
        path: &str,
        body: &B,
    ) -> Result<()> {
        let url = format!("{}{}", self.api_url, path);
        let response = self
            .client
            .put(&url)
            .header("Authorization", format!("Bearer {}", self.token))
            .json(body)
            .send()
            .await
            .context("Failed to connect to API")?;

        let status = response.status();
        if status == StatusCode::UNAUTHORIZED {
            return Err(anyhow!(
                "Authentication failed. Please run 'systemprompt cloud login' again."
            ));
        }
        if status == StatusCode::NO_CONTENT || status.is_success() {
            return Ok(());
        }

        let error_text = response
            .text()
            .await
            .unwrap_or_else(|_| String::from("<failed to read response body>"));

        serde_json::from_str::<ApiError>(&error_text).map_or_else(
            |_| {
                Err(anyhow!(
                    "Request failed with status {}: {}",
                    status,
                    error_text.chars().take(500).collect::<String>()
                ))
            },
            |parsed| Err(anyhow!("{}: {}", parsed.error.code, parsed.error.message)),
        )
    }

    pub(super) async fn delete(&self, path: &str) -> Result<()> {
        let url = format!("{}{}", self.api_url, path);
        let response = self
            .client
            .delete(&url)
            .header("Authorization", format!("Bearer {}", self.token))
            .send()
            .await
            .context("Failed to connect to API")?;

        let status = response.status();

        if status == StatusCode::UNAUTHORIZED {
            return Err(anyhow!(
                "Authentication failed. Please run 'systemprompt cloud login' again."
            ));
        }

        if status == StatusCode::NO_CONTENT {
            return Ok(());
        }

        if !status.is_success() {
            let error_text = response
                .text()
                .await
                .unwrap_or_else(|_| String::from("<failed to read response body>"));

            return serde_json::from_str::<ApiError>(&error_text).map_or_else(
                |_| {
                    Err(anyhow!(
                        "Request failed with status {}: {}",
                        status,
                        error_text.chars().take(500).collect::<String>()
                    ))
                },
                |parsed| Err(anyhow!("{}: {}", parsed.error.code, parsed.error.message)),
            );
        }

        Ok(())
    }

    pub(super) async fn post_empty<T: DeserializeOwned>(&self, path: &str) -> Result<T> {
        let url = format!("{}{}", self.api_url, path);
        let response = self
            .client
            .post(&url)
            .header("Authorization", format!("Bearer {}", self.token))
            .send()
            .await
            .context("Failed to connect to API")?;

        self.handle_response(response).await
    }

    async fn handle_response<T: DeserializeOwned>(&self, response: reqwest::Response) -> Result<T> {
        let status = response.status();

        if status == StatusCode::UNAUTHORIZED {
            return Err(anyhow!(
                "Authentication failed. Please run 'systemprompt cloud login' again."
            ));
        }

        if !status.is_success() {
            let error_text = response
                .text()
                .await
                .unwrap_or_else(|_| String::from("<failed to read response body>"));

            return serde_json::from_str::<ApiError>(&error_text).map_or_else(
                |_| {
                    Err(anyhow!(
                        "Request failed with status {}: {}",
                        status,
                        error_text.chars().take(500).collect::<String>()
                    ))
                },
                |parsed| Err(anyhow!("{}: {}", parsed.error.code, parsed.error.message)),
            );
        }

        response
            .json()
            .await
            .context("Failed to parse API response")
    }

    pub async fn get_user(&self) -> Result<UserMeResponse> {
        self.get(ApiPaths::AUTH_ME).await
    }

    pub async fn list_tenants(&self) -> Result<Vec<Tenant>> {
        let response: ListResponse<Tenant> = self.get(ApiPaths::CLOUD_TENANTS).await?;
        Ok(response.data)
    }

    pub async fn get_plans(&self) -> Result<Vec<Plan>> {
        let plans: Vec<Plan> = self.get(ApiPaths::CLOUD_CHECKOUT_PLANS).await?;
        Ok(plans)
    }

    pub async fn create_checkout(
        &self,
        price_id: &str,
        region: &str,
        redirect_uri: Option<&str>,
    ) -> Result<CheckoutResponse> {
        let request = CheckoutRequest {
            price_id: price_id.to_string(),
            region: region.to_string(),
            redirect_uri: redirect_uri.map(String::from),
        };
        self.post(ApiPaths::CLOUD_CHECKOUT, &request).await
    }

    pub async fn report_activity(&self, event_type: &str, user_id: &str) -> Result<()> {
        let request = ActivityRequest {
            event: event_type.to_string(),
            timestamp: Utc::now().to_rfc3339(),
            data: super::types::ActivityData {
                user_id: user_id.to_string(),
            },
        };
        self.post_no_response(ApiPaths::CLOUD_ACTIVITY, &request)
            .await
    }
}