oxyde_cloud_client/
lib.rs1mod 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 let file = tokio::fs::File::open(path.as_ref()).await?;
96 let metadata = file.metadata().await?;
97 let total_size = metadata.len() as usize;
98 let total_chunks = (total_size + UPLOAD_CHUNK_SIZE - 1) / UPLOAD_CHUNK_SIZE;
99
100 let mut reader = BufReader::new(file);
101 let mut buffer = vec![0u8; UPLOAD_CHUNK_SIZE];
102 let mut chunk_number = 0;
103
104 loop {
105 let n = reader.read(&mut buffer).await?;
106 if n == 0 {
107 break;
108 }
109
110 let part = reqwest::multipart::Part::bytes(buffer[..n].to_vec())
111 .file_name(path.as_ref().file_name().unwrap().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 chunk_number += 1;
133 }
134
135 Ok(())
136 }
137
138 pub async fn upload_done(self, config: &CloudConfig) -> Result<(), ReqwestJsonError> {
139 let _: SuccessResponse = self.post("apps/upload-done").json(config)?.send().await?;
140
141 Ok(())
142 }
143
144 pub async fn log(self, name: &str) -> Result<String, ReqwestJsonError> {
145 let res: LogResponse = self
146 .post("log")
147 .json(&LogRequest {
148 name: name.to_string(),
149 })?
150 .send()
151 .await?;
152
153 Ok(res.log)
154 }
155
156 pub fn post(self, route: &str) -> ClientBuilder {
157 let url = Self::build_route(route);
158
159 ClientBuilder(self.client.post(url)).auth_header(&self.api_key)
160 }
161
162 pub fn get(self, route: &str) -> ClientBuilder {
163 let url = Self::build_route(route);
164
165 ClientBuilder(self.client.get(url)).auth_header(&self.api_key)
166 }
167
168 fn build_route(route: &str) -> String {
169 let base_url = std::env::var("OXYDE_CLOUD_API_URL")
170 .unwrap_or(BASE_URL.unwrap_or(DEFAULT_BASE_URL).to_string());
171 format!("{base_url}{route}")
172 }
173}
174
175pub struct ClientBuilder(reqwest::RequestBuilder);
176
177impl ClientBuilder {
178 pub fn auth_header(self, api_key: &str) -> Self {
179 Self(
180 self.0
181 .header("Authorization", format!("Bearer {}", api_key)),
182 )
183 }
184
185 pub fn body<T: Into<reqwest::Body>>(self, body: T) -> Self {
186 Self(self.0.body(body))
187 }
188
189 pub fn multipart(self, form: Form) -> Self {
190 Self(self.0.multipart(form))
191 }
192
193 pub fn json<Body: Serialize>(self, json: Body) -> Result<ClientBuilder, serde_json::Error> {
194 let json = serde_json::to_string(&json)?;
195
196 Ok(Self(
197 self.0.header("Content-Type", "application/json").body(json),
198 ))
199 }
200
201 pub fn header<K, V>(self, key: K, value: V) -> Self
202 where
203 HeaderName: TryFrom<K>,
204 <HeaderName as TryFrom<K>>::Error: Into<http::Error>,
205 HeaderValue: TryFrom<V>,
206 <HeaderValue as TryFrom<V>>::Error: Into<http::Error>,
207 {
208 Self(self.0.header(key, value))
209 }
210
211 pub async fn send<Resp>(self) -> Result<Resp, reqwest::Error>
212 where
213 for<'de> Resp: Deserialize<'de>,
214 {
215 let res = self.0.send().await?;
216
217 match res.error_for_status_ref() {
218 Ok(_) => Ok(res.json::<Resp>().await?),
219 Err(err) => {
220 let err_text = res.text().await?;
221 error!("Received error:\n{err_text:#?}");
222
223 Err(err)
224 }
225 }
226 }
227}