highlevel-api 0.2.0

Unofficial Rust SDK for the GoHighLevel (HighLevel) API
Documentation
use super::token::TokenData;
use crate::{
    config::HighLevelConfig,
    error::{Error, Result},
};
use reqwest::Client;
use std::sync::Arc;
use url::Url;

const AUTH_BASE_URL: &str = "https://marketplace.gohighlevel.com";
const TOKEN_URL: &str = "https://services.leadconnectorhq.com/oauth/token";

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AccessType {
    Online,
    Offline,
}

impl AccessType {
    pub fn as_str(self) -> &'static str {
        match self {
            AccessType::Online => "online",
            AccessType::Offline => "offline",
        }
    }
}

pub struct OAuthClient {
    config: Arc<HighLevelConfig>,
    inner: Client,
    auth_base_url: String,
}

impl OAuthClient {
    pub fn new(config: Arc<HighLevelConfig>, inner: Client) -> Self {
        Self {
            config,
            inner,
            auth_base_url: AUTH_BASE_URL.to_string(),
        }
    }

    /// Build the authorization URL to redirect the user to for consent.
    pub fn authorization_url(&self, scopes: &[&str], access_type: AccessType) -> Result<String> {
        let mut url = Url::parse(&self.auth_base_url)
            .map_err(|e| Error::Internal(format!("invalid auth base URL: {e}")))?;
        url.set_path("/oauth/chooselocation");
        {
            let mut q = url.query_pairs_mut();
            q.append_pair("response_type", "code");
            if let Some(redirect_uri) = &self.config.redirect_uri {
                q.append_pair("redirect_uri", redirect_uri);
            }
            q.append_pair("client_id", &self.config.client_id);
            q.append_pair("scope", &scopes.join(" "));
            q.append_pair("access_type", access_type.as_str());
        }
        Ok(url.to_string())
    }

    /// Exchange an authorization code for an access + refresh token pair.
    pub async fn exchange_code(&self, code: &str) -> Result<TokenData> {
        let redirect_uri = self.config.redirect_uri.clone().unwrap_or_default();
        let params = [
            ("client_id", self.config.client_id.as_str()),
            ("client_secret", self.config.client_secret.as_str()),
            ("grant_type", "authorization_code"),
            ("code", code),
            ("redirect_uri", &redirect_uri),
        ];
        self.fetch_token(&params).await
    }

    /// Use a refresh token to obtain a new access token.
    pub async fn refresh_token(&self, refresh_token: &str) -> Result<TokenData> {
        let params = [
            ("client_id", self.config.client_id.as_str()),
            ("client_secret", self.config.client_secret.as_str()),
            ("grant_type", "refresh_token"),
            ("refresh_token", refresh_token),
        ];
        self.fetch_token(&params).await
    }

    /// Exchange an agency-level access token for a location-scoped token.
    ///
    /// Equivalent to `POST /oauth/locationToken` with the company and location IDs.
    /// Requires an agency token to be set on the client before calling.
    pub async fn location_token(
        &self,
        company_id: &str,
        location_id: &str,
        agency_token: &str,
    ) -> Result<TokenData> {
        let params = [("company_id", company_id), ("location_id", location_id)];
        let resp = self
            .inner
            .post(TOKEN_URL.trim_end_matches("/token").to_string() + "/locationToken")
            .header("Authorization", format!("Bearer {}", agency_token))
            .header("Version", &self.config.api_version)
            .form(&params)
            .send()
            .await?;

        if !resp.status().is_success() {
            let status = resp.status().as_u16();
            let message = resp.text().await.unwrap_or_default();
            return Err(Error::Api { status, message });
        }
        let mut token: TokenData = resp.json().await?;
        token.mark_issued_now();
        Ok(token)
    }

    async fn fetch_token(&self, params: &[(&str, &str)]) -> Result<TokenData> {
        let resp = self.inner.post(TOKEN_URL).form(params).send().await?;
        if !resp.status().is_success() {
            let status = resp.status().as_u16();
            let message = resp.text().await.unwrap_or_default();
            return Err(Error::Api { status, message });
        }
        let mut token: TokenData = resp.json().await?;
        token.mark_issued_now();
        Ok(token)
    }
}