somfy-sdk 0.2.2

A Rust-based SDK for interacting with Somfy smart home devices and APIs.
Documentation
use crate::commands::cancel_all_executions::{
    CancelAllExecutionsCommand, CancelAllExecutionsResponse,
};
use crate::commands::cancel_execution::{CancelExecutionCommand, CancelExecutionResponse};

use crate::commands::fetch_events::{FetchEventsCommand, FetchEventsResponse};
use crate::commands::get_current_executions::{
    GetCurrentExecutionsCommand, GetCurrentExecutionsResponse,
};
use crate::commands::get_device::{GetDeviceCommand, GetDeviceResponse};
use crate::commands::get_device_state::{GetDeviceStateCommand, GetDeviceStateResponse};
use crate::commands::get_device_states::{GetDeviceStatesCommand, GetDeviceStatesResponse};
use crate::commands::get_devices::{GetDevicesCommand, GetDevicesResponse};
use crate::commands::get_devices_by_controllable::{
    GetDevicesByControllableCommand, GetDevicesByControllableResponse,
};
use crate::commands::get_execution::{GetExecutionCommand, GetExecutionResponse};
use crate::commands::get_setup::{GetSetupCommand, GetSetupResponse};
use crate::commands::get_setup_gateways::{GetGatewaysCommand, GetGatewaysResponse};
use crate::commands::get_version::{GetVersionCommand, GetVersionResponse};
use crate::commands::register_event_listener::{
    RegisterEventListenerCommand, RegisterEventListenerResponse,
};
use crate::commands::traits::SomfyApiRequestResponse;
use crate::commands::traits::{HttpMethod, RequestData, SomfyApiRequestCommand};
use crate::commands::unregister_event_listener::{
    UnregisterEventListenerCommand, UnregisterEventListenerResponse,
};
use crate::config::tls_cert::TlsCertHandler;
use crate::err::http::RequestError;
use log::debug;
use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION};
use reqwest::{Certificate, Client, ClientBuilder, Response};

#[derive(Debug, Clone, PartialEq)]
pub enum HttpProtocol {
    HTTP,
    HTTPS,
}
#[derive(Debug, Clone, PartialEq)]
pub enum CertificateHandling {
    CertProvided(String),
    DefaultCert,
    NoCustomCert,
}

#[derive(Debug, Clone, PartialEq)]
pub struct ApiClientConfig {
    pub cert_handling: CertificateHandling,
    pub protocol: HttpProtocol,
    pub url: String,
    pub port: usize,
    pub api_key: String,
}

#[derive(Debug, Clone)]
pub struct ApiClient {
    config: ApiClientConfig,
    http_client: Client,
}

const DEFAULT_PORT: usize = 8443;

impl ApiClient {
    pub async fn new(config: ApiClientConfig) -> Result<Self, RequestError> {
        debug!("Initialized ApiClient with Config: {config:?}");
        let http_client = Self::build_client(&config).await?;
        Ok(Self {
            config,
            http_client,
        })
    }

    pub async fn from(id: &str, api_key: &str) -> Result<Self, RequestError> {
        let config = ApiClientConfig {
            url: format!("gateway-{id}.local"),
            port: DEFAULT_PORT,
            api_key: api_key.to_string(),
            protocol: HttpProtocol::HTTPS,
            cert_handling: CertificateHandling::DefaultCert,
        };
        Self::new(config).await
    }

    async fn build_client(config: &ApiClientConfig) -> Result<Client, RequestError> {
        let headers = Self::generate_default_headers(config)?;
        let mut client = ClientBuilder::new().default_headers(headers);

        if let Some(certificate) = Self::ensure_cert(config).await? {
            client = client.add_root_certificate(certificate)
        }

        Ok(client.build()?)
    }

    async fn make_post_request(
        &self,
        request_data: RequestData,
    ) -> Result<Response, reqwest::Error> {
        let content_len = &request_data.get_content_length();
        let path = self.generate_base_url(&request_data);

        self.http_client
            .post(&path)
            .body(request_data.body)
            .header("content-length", content_len)
            .header("content-type", "application/json")
            .send()
            .await?
            .error_for_status()
    }

    async fn make_get_request(
        &self,
        request_data: RequestData,
    ) -> Result<Response, reqwest::Error> {
        let path = self.generate_base_url(&request_data);
        self.http_client.get(&path).send().await?.error_for_status()
    }

    async fn make_delete_request(
        &self,
        request_data: RequestData,
    ) -> Result<Response, reqwest::Error> {
        let path = self.generate_base_url(&request_data);
        self.http_client
            .delete(&path)
            .send()
            .await?
            .error_for_status()
    }

    async fn make_api_request(
        &self,
        request_data: RequestData,
    ) -> Result<Response, reqwest::Error> {
        match request_data.method {
            HttpMethod::GET => self.make_get_request(request_data).await,
            HttpMethod::POST => self.make_post_request(request_data).await,
            HttpMethod::DELETE => self.make_delete_request(request_data).await,
        }
    }

    pub async fn execute<C>(&self, command: C) -> Result<C::Response, RequestError>
    where
        C: SomfyApiRequestCommand,
    {
        let request_data = command.to_request()?;
        let response = self.make_api_request(request_data).await?;

        let body = response.text().await?;
        C::Response::from_body(body.as_str())
    }

    async fn ensure_cert(config: &ApiClientConfig) -> Result<Option<Certificate>, RequestError> {
        Ok(match &config.cert_handling {
            CertificateHandling::CertProvided(path) => {
                let crt = std::fs::read(path).map_err(|_| RequestError::Cert)?;
                Some(Certificate::from_pem(&crt)?)
            }
            CertificateHandling::DefaultCert => {
                let cert = TlsCertHandler::ensure_local_certificate()
                    .await
                    .map_err(|_| RequestError::Cert)?;

                Some(cert)
            }
            CertificateHandling::NoCustomCert => None,
        })
    }

    fn generate_base_url(&self, request_data: &RequestData) -> String {
        let protocol = match self.config.protocol {
            HttpProtocol::HTTP => "http",
            HttpProtocol::HTTPS => "https",
        };

        let path = format!(
            "{}://{}:{}{}",
            protocol, self.config.url, self.config.port, request_data.path
        );
        path
    }

    fn generate_default_headers(config: &ApiClientConfig) -> Result<HeaderMap, RequestError> {
        let mut headers = HeaderMap::new();
        let bearer_token = HeaderValue::from_str(format!("Bearer {}", config.api_key).as_str())
            .map_err(|e| RequestError::Server(e.into()))?;
        headers.insert(AUTHORIZATION, bearer_token);
        Ok(headers)
    }

    pub async fn get_version(&self) -> Result<GetVersionResponse, RequestError> {
        self.execute(GetVersionCommand).await
    }

    pub async fn get_gateways(&self) -> Result<GetGatewaysResponse, RequestError> {
        self.execute(GetGatewaysCommand).await
    }

    pub async fn get_devices(&self) -> Result<GetDevicesResponse, RequestError> {
        self.execute(GetDevicesCommand).await
    }

    pub async fn get_device(&self, device_url: &str) -> Result<GetDeviceResponse, RequestError> {
        self.execute(GetDeviceCommand { device_url }).await
    }

    pub async fn get_setup(&self) -> Result<GetSetupResponse, RequestError> {
        self.execute(GetSetupCommand).await
    }

    pub async fn get_device_states(
        &self,
        device_url: &str,
    ) -> Result<GetDeviceStatesResponse, RequestError> {
        self.execute(GetDeviceStatesCommand { device_url }).await
    }

    pub async fn get_device_state(
        &self,
        device_url: &str,
        state_name: &str,
    ) -> Result<GetDeviceStateResponse, RequestError> {
        self.execute(GetDeviceStateCommand {
            device_url,
            state_name,
        })
        .await
    }

    pub async fn get_devices_by_controllable(
        &self,
        controllable_name: &str,
    ) -> Result<GetDevicesByControllableResponse, RequestError> {
        self.execute(GetDevicesByControllableCommand { controllable_name })
            .await
    }

    pub async fn register_event_listener(
        &self,
    ) -> Result<RegisterEventListenerResponse, RequestError> {
        self.execute(RegisterEventListenerCommand).await
    }

    pub async fn fetch_events(
        &self,
        listener_id: &str,
    ) -> Result<FetchEventsResponse, RequestError> {
        self.execute(FetchEventsCommand { listener_id }).await
    }

    pub async fn unregister_event_listener(
        &self,
        listener_id: &str,
    ) -> Result<UnregisterEventListenerResponse, RequestError> {
        self.execute(UnregisterEventListenerCommand { listener_id })
            .await
    }

    #[cfg(feature = "generic-exec")]
    pub async fn execute_actions(
        &self,
        action_group: &crate::commands::types::ActionGroup,
    ) -> Result<crate::commands::execute_action_group::ExecuteActionGroupResponse, RequestError>
    {
        self.execute(
            crate::commands::execute_action_group::ExecuteActionGroupCommand { action_group },
        )
        .await
    }

    pub async fn get_current_executions(
        &self,
    ) -> Result<GetCurrentExecutionsResponse, RequestError> {
        self.execute(GetCurrentExecutionsCommand).await
    }

    pub async fn get_execution(
        &self,
        execution_id: &str,
    ) -> Result<GetExecutionResponse, RequestError> {
        self.execute(GetExecutionCommand { execution_id }).await
    }

    pub async fn cancel_all_executions(&self) -> Result<CancelAllExecutionsResponse, RequestError> {
        self.execute(CancelAllExecutionsCommand).await
    }

    pub async fn cancel_execution(
        &self,
        execution_id: &str,
    ) -> Result<CancelExecutionResponse, RequestError> {
        self.execute(CancelExecutionCommand { execution_id }).await
    }
}

#[cfg(test)]
mod api_client_tests {
    use crate::api_client::{
        ApiClient, ApiClientConfig, CertificateHandling, HttpProtocol, DEFAULT_PORT,
    };
    use rstest::*;

    #[fixture]
    async fn api_client() -> ApiClient {
        ApiClient::from("0000-1111-2222", "my_key")
            .await
            .expect("should create an ApiClient")
    }

    #[tokio::test]
    async fn creates_api_client_with_new() {
        let api_client = ApiClient::new(ApiClientConfig {
            protocol: HttpProtocol::HTTP,
            port: 2000,
            url: "somedomain.com".to_string(),
            api_key: "my_key".to_string(),
            cert_handling: CertificateHandling::DefaultCert,
        })
        .await
        .expect("should create an ApiClient");
        assert_eq!(api_client.config.protocol, HttpProtocol::HTTP);
        assert_eq!(api_client.config.port, 2000);
        assert_eq!(api_client.config.url, "somedomain.com".to_string());
        assert_eq!(api_client.config.api_key, "my_key".to_string());
        assert_eq!(
            api_client.config.cert_handling,
            CertificateHandling::DefaultCert
        );
    }

    #[tokio::test]
    async fn creates_api_client_with_from() {
        let api_client = ApiClient::from("0000-1111-2222", "my_key")
            .await
            .expect("should create an ApiClient");
        assert_eq!(api_client.config.port, DEFAULT_PORT);
        assert_eq!(
            api_client.config.url,
            "gateway-0000-1111-2222.local".to_string()
        );
        assert_eq!(
            api_client.config.cert_handling,
            CertificateHandling::DefaultCert
        );
        assert_eq!(api_client.config.protocol, HttpProtocol::HTTPS);
        assert_eq!(api_client.config.api_key, "my_key".to_string());
    }
}