Skip to main content

torii_lib/cloud/
mod.rs

1//! Authenticated HTTP client for the gitorii.com backend.
2//!
3//! Every call carries `Authorization: Bearer gitorii_sk_<key>` from
4//! `crate::auth`. Translates HTTP status into actionable `ToriiError`s the
5//! CLI can surface verbatim:
6//!   401  → "API key rejected. Run `torii auth login` to refresh."
7//!   402  → "current plan <p> insufficient. Upgrade at <url>."
8//!   403  → "organization suspended."
9//!   5xx  → "server error: …".
10
11pub mod whoami;
12
13use reqwest::blocking::{Client, RequestBuilder, Response};
14use reqwest::header::AUTHORIZATION;
15use std::time::Duration;
16
17use crate::auth::ApiKey;
18use crate::error::{Result, ToriiError};
19
20const UA: &str = concat!("torii/", env!("CARGO_PKG_VERSION"));
21
22pub struct CloudClient {
23    http: Client,
24    key: ApiKey,
25}
26
27impl CloudClient {
28    pub fn new(key: ApiKey) -> Self {
29        let http = Client::builder()
30            .user_agent(UA)
31            .timeout(Duration::from_secs(15))
32            .build()
33            .expect("reqwest client builds");
34        Self { http, key }
35    }
36
37    pub fn endpoint(&self) -> &str {
38        &self.key.endpoint
39    }
40
41    fn url(&self, path: &str) -> String {
42        format!("{}{}", self.key.endpoint.trim_end_matches('/'), path)
43    }
44
45    fn get(&self, path: &str) -> RequestBuilder {
46        self.http
47            .get(self.url(path))
48            .header(AUTHORIZATION, format!("Bearer {}", self.key.key))
49    }
50
51    #[allow(dead_code)] // used by future endpoints (transpile etc.)
52    fn post(&self, path: &str) -> RequestBuilder {
53        self.http
54            .post(self.url(path))
55            .header(AUTHORIZATION, format!("Bearer {}", self.key.key))
56    }
57}
58
59/// Convert HTTP status into a friendly error before the caller sees raw body.
60/// On 200..=299 returns the response unchanged.
61pub(crate) fn check_status(resp: Response) -> Result<Response> {
62    let status = resp.status();
63    if status.is_success() {
64        return Ok(resp);
65    }
66    let body = resp.text().unwrap_or_default();
67    let msg = match status.as_u16() {
68        401 => "API key rejected. Run `torii auth login` to refresh.".to_string(),
69        402 => format!(
70            "your plan does not include this feature. Upgrade at https://gitorii.com/upgrade ({})",
71            short_body(&body)
72        ),
73        403 => "organization suspended. Contact support@gitorii.com.".to_string(),
74        404 => "endpoint not found — CLI may be outdated.".to_string(),
75        s if (500..=599).contains(&s) => format!("server error {}: {}", s, short_body(&body)),
76        s => format!("unexpected HTTP {}: {}", s, short_body(&body)),
77    };
78    Err(ToriiError::PlatformApi { provider: "gitorii.com".into(), status: status.as_u16(), message: msg })
79}
80
81fn short_body(body: &str) -> String {
82    let trimmed = body.trim();
83    // Cap by chars (not bytes) so multi-byte UTF-8 server messages don't
84    // panic on the slice. 200 chars ≈ enough to debug an API rejection.
85    if trimmed.chars().count() > 200 {
86        let head: String = trimmed.chars().take(200).collect();
87        format!("{}…", head)
88    } else {
89        trimmed.to_string()
90    }
91}