oxyde_cloud_client/
lib.rs

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