oxyde_cloud_client/
lib.rs

1use anyhow::{Context, Result};
2use headers_core::Header;
3use log::error;
4use reqwest::header::{HeaderName, HeaderValue};
5use reqwest::multipart::Form;
6use serde::{Deserialize, Serialize};
7use std::path::Path;
8use tokio::io::{AsyncReadExt, AsyncSeekExt, SeekFrom};
9
10use oxyde_cloud_common::config::CloudConfig;
11use oxyde_cloud_common::net::{
12    AppMeta, CheckAvailabilityResponse, LogRequest, LogResponse, LoginResponse, NewAppRequest,
13    NewTeamRequest, SetTeamNameRequest, SuccessResponse, Team,
14};
15
16const BASE_URL: Option<&str> = option_env!("OXYDE_CLOUD_API_URL");
17const DEFAULT_BASE_URL: &str = "https://oxyde.cloud/api/v1/";
18const UPLOAD_CHUNK_SIZE: usize = 90 * 1024 * 1024;
19
20#[derive(Clone)]
21pub struct Client {
22    client: reqwest::Client,
23    api_key: String,
24}
25
26impl Client {
27    pub fn new(api_key: String) -> Self {
28        Self {
29            client: reqwest::Client::new(),
30            api_key,
31        }
32    }
33
34    pub async fn teams(self) -> Result<Vec<Team>> {
35        let teams = self
36            .get("teams")
37            .send()
38            .await
39            .context("Failed to fetch teams")?;
40
41        Ok(teams)
42    }
43
44    pub async fn new_app(self, app_slug: &str, team_slug: &str, name: &str) -> Result<bool> {
45        let CheckAvailabilityResponse { available } = self
46            .post("apps/new")
47            .json(&NewAppRequest {
48                app_slug: app_slug.to_string(),
49                team_slug: team_slug.to_string(),
50                name: name.to_string()
51            })
52            .context("Failed to serialize new app request")?
53            .send()
54            .await
55            .with_context(|| format!("Failed to check app slug availability: {app_slug}"))?;
56
57        Ok(available)
58    }
59
60    pub async fn new_team(self, team_slug: &str) -> Result<bool> {
61        let CheckAvailabilityResponse { available } = self
62            .post("teams/new")
63            .json(&NewTeamRequest {
64                team_slug: team_slug.to_string(),
65            })
66            .context("Failed to serialize new team request")?
67            .send()
68            .await
69            .with_context(|| format!("Failed to check team slug availability: {team_slug}"))?;
70
71        Ok(available)
72    }
73
74    pub async fn set_team_name(self, team_slug: &str, team_name: &str) -> Result<()> {
75        let _: SuccessResponse = self
76            .post("teams/name")
77            .json(&SetTeamNameRequest {
78                team_slug: team_slug.to_string(),
79                team_name: team_name.to_string(),
80            })
81            .context("Failed to serialize set team name request")?
82            .send()
83            .await
84            .with_context(|| format!("Failed to set team name for team: {team_slug}"))?;
85
86        Ok(())
87    }
88
89    pub async fn login(self) -> Result<LoginResponse> {
90        self.post("login")
91            .json(())
92            .context("Failed to serialize login request")?
93            .send()
94            .await
95            .context("Failed to login with API key")
96    }
97
98    pub async fn upload_file(
99        self,
100        app_slug: impl AsRef<str>,
101        path: impl AsRef<Path>,
102    ) -> Result<()> {
103        let metadata = tokio::fs::metadata(path.as_ref()).await.with_context(|| {
104            format!(
105                "Failed to read metadata for file: {}",
106                path.as_ref().display()
107            )
108        })?;
109        let total_size = metadata.len() as usize;
110        let total_chunks = (total_size + UPLOAD_CHUNK_SIZE - 1) / UPLOAD_CHUNK_SIZE;
111
112        for chunk_number in 0..total_chunks {
113            let offset = chunk_number * UPLOAD_CHUNK_SIZE;
114            let len = std::cmp::min(UPLOAD_CHUNK_SIZE, total_size - offset);
115
116            let mut file = tokio::fs::File::open(path.as_ref())
117                .await
118                .with_context(|| format!("Failed to open file: {}", path.as_ref().display()))?;
119            file.seek(SeekFrom::Start(offset as u64))
120                .await
121                .context("Failed to seek in file")?;
122
123            let mut buffer = vec![0u8; len];
124            let n = file
125                .read_exact(&mut buffer)
126                .await
127                .context("Failed to read file chunk")?;
128
129            let part = reqwest::multipart::Part::bytes(buffer[..n].to_vec())
130                .file_name(path.as_ref().to_string_lossy().to_string());
131
132            let form = reqwest::multipart::Form::new()
133                .part("file", part)
134                .text("chunk_number", chunk_number.to_string())
135                .text("total_chunks", total_chunks.to_string());
136
137            let _: SuccessResponse = self
138                .clone()
139                .post("apps/upload-file")
140                .multipart(form)
141                .header(
142                    AppMeta::name(),
143                    AppMeta {
144                        app_slug: app_slug.as_ref().to_string(),
145                    }
146                    .to_string_value(),
147                )
148                .send()
149                .await
150                .with_context(|| {
151                    format!(
152                        "Failed to upload file chunk {}/{}",
153                        chunk_number + 1,
154                        total_chunks
155                    )
156                })?;
157        }
158
159        Ok(())
160    }
161
162    pub async fn upload_done(self, config: &CloudConfig) -> Result<()> {
163        let _: SuccessResponse = self
164            .post("apps/upload-done")
165            .json(config)
166            .context("Failed to serialize upload done request")?
167            .send()
168            .await
169            .context("Failed to signal upload completion")?;
170
171        Ok(())
172    }
173
174    pub async fn log(self, name: &str) -> Result<String> {
175        let res: LogResponse = self
176            .post("log")
177            .json(&LogRequest {
178                name: name.to_string(),
179            })
180            .context("Failed to serialize log request")?
181            .send()
182            .await
183            .with_context(|| format!("Failed to fetch logs for app: {name}"))?;
184
185        Ok(res.log)
186    }
187
188    pub fn post(self, route: &str) -> ClientBuilder {
189        let url = Self::build_route(route);
190
191        ClientBuilder(self.client.post(url)).auth_header(&self.api_key)
192    }
193
194    pub fn get(self, route: &str) -> ClientBuilder {
195        let url = Self::build_route(route);
196        println!("GET request URL: {}", url);
197
198        ClientBuilder(self.client.get(url)).auth_header(&self.api_key)
199    }
200
201    fn build_route(route: &str) -> String {
202        let base_url = std::env::var("OXYDE_CLOUD_API_URL")
203            .unwrap_or(BASE_URL.unwrap_or(DEFAULT_BASE_URL).to_string());
204        format!("{base_url}{route}")
205    }
206}
207
208pub struct ClientBuilder(reqwest::RequestBuilder);
209
210impl ClientBuilder {
211    pub fn auth_header(self, api_key: &str) -> Self {
212        Self(self.0.header("Authorization", format!("Bearer {api_key}")))
213    }
214
215    pub fn body<T: Into<reqwest::Body>>(self, body: T) -> Self {
216        Self(self.0.body(body))
217    }
218
219    pub fn multipart(self, form: Form) -> Self {
220        Self(self.0.multipart(form))
221    }
222
223    pub fn json<Body: Serialize>(self, json: Body) -> Result<ClientBuilder> {
224        let json =
225            serde_json::to_string(&json).context("Failed to serialize request body to JSON")?;
226
227        Ok(Self(
228            self.0.header("Content-Type", "application/json").body(json),
229        ))
230    }
231
232    pub fn header<K, V>(self, key: K, value: V) -> Self
233    where
234        HeaderName: TryFrom<K>,
235        <HeaderName as TryFrom<K>>::Error: Into<http::Error>,
236        HeaderValue: TryFrom<V>,
237        <HeaderValue as TryFrom<V>>::Error: Into<http::Error>,
238    {
239        Self(self.0.header(key, value))
240    }
241
242    pub async fn send<Resp>(self) -> Result<Resp>
243    where
244        for<'de> Resp: Deserialize<'de>,
245    {
246        let res = self.0.send().await.context("Failed to send HTTP request")?;
247
248        match res.error_for_status_ref() {
249            Ok(_) => res
250                .json::<Resp>()
251                .await
252                .context("Failed to parse response JSON"),
253            Err(err) => {
254                let err_text = res
255                    .text()
256                    .await
257                    .unwrap_or_else(|_| "Failed to read error response".to_string());
258                error!("Received error:\n{err_text:#?}");
259
260                Err(err).context("HTTP request failed")
261            }
262        }
263    }
264}