heyo-sdk 0.1.0

Rust SDK for the Heyo cloud sandbox API.
Documentation
//! HTTP transport. Wraps `reqwest::Client` with bearer auth, timeouts, and
//! HTTP-error translation matching `sdk-ts/src/client.ts`.

use std::sync::Arc;
use std::time::Duration;

use reqwest::header::{HeaderMap, HeaderValue, ACCEPT, AUTHORIZATION, CONTENT_TYPE};
use reqwest::{Method, Response, StatusCode};
use serde::de::DeserializeOwned;
use serde::Serialize;
use url::Url;

use crate::errors::HeyoError;

const DEFAULT_BASE_URL: &str = "https://server.heyo.computer";
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60);

/// Construction options for [`HeyoClient`]. Mirrors `HeyoClientOptions` in
/// `sdk-ts/src/client.ts`.
#[derive(Debug, Default, Clone)]
pub struct HeyoClientOptions {
    /// Bearer token. Falls back to `HEYO_API_KEY` env var when `None`.
    pub api_key: Option<String>,
    /// Cloud base URL. Default: `https://server.heyo.computer`.
    pub base_url: Option<String>,
    /// Per-request timeout. Default: 60 seconds.
    pub timeout: Option<Duration>,
}

/// Optional per-request knobs.
#[derive(Debug, Default, Clone)]
pub struct RequestOptions {
    pub timeout: Option<Duration>,
    /// Query string parameters appended verbatim.
    pub query: Vec<(String, String)>,
}

#[derive(Clone)]
pub struct HeyoClient {
    inner: Arc<Inner>,
}

struct Inner {
    api_key: String,
    base_url: String,
    http: reqwest::Client,
    default_timeout: Duration,
}

impl HeyoClient {
    pub fn new(opts: HeyoClientOptions) -> Result<Self, HeyoError> {
        let api_key = opts
            .api_key
            .or_else(|| std::env::var("HEYO_API_KEY").ok())
            .ok_or(HeyoError::Authentication)?;
        let base_url = opts
            .base_url
            .unwrap_or_else(|| DEFAULT_BASE_URL.to_string())
            .trim_end_matches('/')
            .to_string();
        let http = reqwest::Client::builder()
            .build()
            .map_err(|e| HeyoError::Connection(e.to_string()))?;
        Ok(Self {
            inner: Arc::new(Inner {
                api_key,
                base_url,
                http,
                default_timeout: opts.timeout.unwrap_or(DEFAULT_TIMEOUT),
            }),
        })
    }

    pub fn base_url(&self) -> &str {
        &self.inner.base_url
    }

    #[allow(dead_code)]
    pub(crate) fn api_key(&self) -> &str {
        &self.inner.api_key
    }

    /// Issue a request and deserialize the JSON response.
    pub async fn request<T: DeserializeOwned>(
        &self,
        method: Method,
        path: &str,
        body: Option<&(impl Serialize + ?Sized)>,
        opts: RequestOptions,
    ) -> Result<T, HeyoError> {
        let bytes = self.request_bytes(method, path, body, opts).await?;
        if bytes.is_empty() {
            // Caller asked for T but the server returned no body. Try to
            // deserialize "null" so types like `()` (via serde_json::Value)
            // or Option<T> still work.
            return serde_json::from_slice::<T>(b"null").map_err(|e| {
                HeyoError::api(0, format!("empty response body could not be parsed: {}", e))
            });
        }
        serde_json::from_slice::<T>(&bytes)
            .map_err(|e| HeyoError::api(0, format!("invalid JSON response: {}", e)))
    }

    /// Like `request` but returns the raw response bytes (used by binary
    /// endpoints like `/sqlite-databases/:id/file`).
    pub async fn request_bytes(
        &self,
        method: Method,
        path: &str,
        body: Option<&(impl Serialize + ?Sized)>,
        opts: RequestOptions,
    ) -> Result<Vec<u8>, HeyoError> {
        let response = self.raw_request(method, path, body, opts).await?;
        self.consume_response(response, path).await
    }

    /// Issue the request and return the raw `reqwest::Response`. Use this
    /// when you need response headers (e.g. checkout's
    /// `X-Heyo-Data-Version`) or want to stream the body.
    pub async fn raw_request(
        &self,
        method: Method,
        path: &str,
        body: Option<&(impl Serialize + ?Sized)>,
        opts: RequestOptions,
    ) -> Result<Response, HeyoError> {
        let url = self.build_url(path, &opts.query)?;
        let mut headers = HeaderMap::new();
        headers.insert(ACCEPT, HeaderValue::from_static("application/json"));
        let auth = format!("Bearer {}", self.inner.api_key);
        headers.insert(
            AUTHORIZATION,
            HeaderValue::from_str(&auth)
                .map_err(|e| HeyoError::api(0, format!("invalid api key header: {}", e)))?,
        );
        let mut builder = self
            .inner
            .http
            .request(method, url)
            .headers(headers)
            .timeout(opts.timeout.unwrap_or(self.inner.default_timeout));
        if let Some(body) = body {
            builder = builder
                .header(CONTENT_TYPE, "application/json")
                .json(body);
        }
        builder
            .send()
            .await
            .map_err(|e| HeyoError::api(0, format!("network error calling {}: {}", path, e)))
    }

    /// POST raw bytes (Content-Type: application/octet-stream by default) and
    /// return the raw response. Used by `Database::checkin`.
    pub async fn put_bytes(
        &self,
        path: &str,
        body: Vec<u8>,
        content_type: &str,
        opts: RequestOptions,
    ) -> Result<Response, HeyoError> {
        let url = self.build_url(path, &opts.query)?;
        let mut headers = HeaderMap::new();
        let auth = format!("Bearer {}", self.inner.api_key);
        headers.insert(
            AUTHORIZATION,
            HeaderValue::from_str(&auth)
                .map_err(|e| HeyoError::api(0, format!("invalid api key header: {}", e)))?,
        );
        headers.insert(
            CONTENT_TYPE,
            HeaderValue::from_str(content_type)
                .map_err(|e| HeyoError::api(0, format!("invalid content-type: {}", e)))?,
        );
        self.inner
            .http
            .request(Method::PUT, url)
            .headers(headers)
            .timeout(opts.timeout.unwrap_or(self.inner.default_timeout))
            .body(body)
            .send()
            .await
            .map_err(|e| HeyoError::api(0, format!("network error calling {}: {}", path, e)))
    }

    /// Build the WS URL for a given path. Scheme is swapped (`http`→`ws`,
    /// `https`→`wss`).
    pub(crate) fn ws_url(&self, path: &str) -> Result<String, HeyoError> {
        let http_url = self.build_url(path, &[])?;
        let mut parsed = Url::parse(&http_url)
            .map_err(|e| HeyoError::Connection(format!("bad URL {}: {}", http_url, e)))?;
        let scheme = match parsed.scheme() {
            "https" => "wss",
            "http" => "ws",
            other => return Err(HeyoError::Connection(format!("unsupported scheme {}", other))),
        };
        parsed
            .set_scheme(scheme)
            .map_err(|_| HeyoError::Connection("could not swap to ws scheme".into()))?;
        Ok(parsed.to_string())
    }

    pub(crate) fn ws_authorization(&self) -> String {
        format!("Bearer {}", self.inner.api_key)
    }

    fn build_url(&self, path: &str, query: &[(String, String)]) -> Result<String, HeyoError> {
        let clean = if path.starts_with('/') {
            path.to_string()
        } else {
            format!("/{}", path)
        };
        let mut url = Url::parse(&format!("{}{}", self.inner.base_url, clean))
            .map_err(|e| HeyoError::api(0, format!("bad URL {}{}: {}", self.inner.base_url, clean, e)))?;
        if !query.is_empty() {
            let mut pairs = url.query_pairs_mut();
            for (k, v) in query {
                pairs.append_pair(k, v);
            }
        }
        Ok(url.to_string())
    }

    async fn consume_response(
        &self,
        response: Response,
        path: &str,
    ) -> Result<Vec<u8>, HeyoError> {
        let status = response.status();
        let bytes = response
            .bytes()
            .await
            .map_err(|e| HeyoError::api(0, format!("read body for {}: {}", path, e)))?;
        if status.is_success() {
            if status == StatusCode::NO_CONTENT || status == StatusCode::RESET_CONTENT {
                return Ok(Vec::new());
            }
            return Ok(bytes.to_vec());
        }

        let mut message = format!("{} {}", status.as_u16(), status.canonical_reason().unwrap_or(""));
        let mut parsed_body: Option<serde_json::Value> = None;
        if !bytes.is_empty() {
            if let Ok(v) = serde_json::from_slice::<serde_json::Value>(&bytes) {
                if let Some(m) = v.get("message").and_then(|x| x.as_str()) {
                    message = m.to_string();
                } else if let Some(e) = v.get("error").and_then(|x| x.as_str()) {
                    message = e.to_string();
                }
                parsed_body = Some(v);
            } else if let Ok(text) = std::str::from_utf8(&bytes) {
                message = text.to_string();
            }
        }

        let with_path = format!("{} (calling {})", message, path);
        Err(match status {
            StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => HeyoError::Authentication,
            StatusCode::NOT_FOUND => HeyoError::NotFound(with_path),
            StatusCode::BAD_REQUEST | StatusCode::UNPROCESSABLE_ENTITY => {
                HeyoError::InvalidArgument(with_path)
            }
            _ => HeyoError::api_with_body(status.as_u16(), with_path, parsed_body),
        })
    }
}

impl std::fmt::Debug for HeyoClient {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("HeyoClient")
            .field("base_url", &self.inner.base_url)
            .field("default_timeout", &self.inner.default_timeout)
            .finish_non_exhaustive()
    }
}