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