forc_publish/
forc_pub_client.rs1use crate::error::Error;
2use crate::error::Result;
3use reqwest::StatusCode;
4use semver::Version;
5use serde::{Deserialize, Serialize};
6use std::fs;
7use std::path::Path;
8use url::Url;
9use uuid::Uuid;
10
11#[derive(Serialize, Debug)]
13pub struct PublishRequest {
14 pub upload_id: Uuid,
15}
16
17#[derive(Serialize, Deserialize, Debug)]
19pub struct PublishResponse {
20 pub name: String,
21 pub version: Version,
22}
23
24#[derive(Deserialize, Debug)]
26pub struct UploadResponse {
27 pub upload_id: Uuid,
28}
29
30pub struct ForcPubClient {
31 client: reqwest::Client,
32 uri: Url,
33}
34
35impl ForcPubClient {
36 pub fn new(uri: Url) -> Self {
37 let client = reqwest::Client::new();
38 Self { client, uri }
39 }
40
41 pub async fn upload<P: AsRef<Path>>(&self, file_path: P, forc_version: &str) -> Result<Uuid> {
43 use futures_util::StreamExt;
44 use std::io::{stdout, Write};
45 let url = self
46 .uri
47 .join(&format!("upload_project?forc_version={forc_version}"))?;
48 let file_bytes = fs::read(file_path)?;
49
50 let response = self
51 .client
52 .post(url)
53 .header("Content-Type", "application/gzip")
54 .body(file_bytes)
55 .send()
56 .await;
57
58 if let Ok(response) = response {
59 let mut stream = response.bytes_stream();
60
61 while let Some(chunk) = stream.next().await {
68 match chunk {
69 Ok(bytes) => {
70 let event_str = String::from_utf8_lossy(&bytes);
71 for event in event_str.split("\n\n") {
72 if let Some(stripped) = event.strip_prefix("data:") {
73 let data = &stripped.trim();
74 if let Ok(upload_response) =
75 serde_json::from_str::<UploadResponse>(data)
76 {
77 return Ok(upload_response.upload_id);
78 } else if data.starts_with("{") {
79 return Err(Error::ApiResponseError {
81 status: StatusCode::INTERNAL_SERVER_ERROR,
82 error: data.to_string(),
83 });
84 } else {
85 print!("\r\x1b[2K => {data}");
87 stdout().flush().unwrap();
88 }
89 }
90 }
95 }
96 Err(e) => {
97 return Err(Error::HttpError(e));
98 }
99 }
100 }
101 Err(Error::ServerError)
102 } else {
103 eprintln!("Error during upload initiation: {response:?}");
104 Err(Error::ServerError)
105 }
106 }
107
108 pub async fn publish(&self, upload_id: Uuid, auth_token: &str) -> Result<PublishResponse> {
110 let url = self.uri.join("publish")?;
111 let publish_request = PublishRequest { upload_id };
112
113 let response = self
114 .client
115 .post(url)
116 .header("Content-Type", "application/json")
117 .header("Authorization", format!("Bearer {auth_token}"))
118 .json(&publish_request)
119 .send()
120 .await?;
121
122 let status = response.status();
123
124 if status.is_success() {
125 let publish_response: PublishResponse = response.json().await?;
126 Ok(publish_response)
127 } else {
128 Err(Error::from_response(response).await)
129 }
130 }
131}
132
133#[cfg(test)]
134mod test {
135 use super::*;
136 use reqwest::StatusCode;
137 use serde_json::json;
138 use std::fs;
139 use tempfile::NamedTempFile;
140 use uuid::Uuid;
141 use wiremock::matchers::{method, path, query_param};
142 use wiremock::{Mock, MockServer, ResponseTemplate};
143
144 async fn get_mock_client_server() -> (ForcPubClient, MockServer) {
145 let mock_server = MockServer::start().await;
146 let url = Url::parse(&mock_server.uri()).expect("url");
147 let mock_client = ForcPubClient::new(url);
148 (mock_client, mock_server)
149 }
150
151 #[tokio::test]
152 async fn test_upload_success() {
153 let (client, mock_server) = get_mock_client_server().await;
154 let upload_id = Uuid::new_v4();
155
156 let sse_body = format!(
158 "data: uploading...\n\n\
159 data: {{\"upload_id\":\"{upload_id}\"}}\n\n"
160 );
161
162 Mock::given(method("POST"))
163 .and(path("/upload_project"))
164 .and(query_param("forc_version", "0.66.5"))
165 .respond_with(
166 ResponseTemplate::new(200)
167 .insert_header("Content-Type", "text/event-stream")
168 .set_body_string(sse_body),
169 )
170 .mount(&mock_server)
171 .await;
172
173 let temp_file = NamedTempFile::new().unwrap();
175 fs::write(temp_file.path(), b"test content").unwrap();
176
177 let result = client.upload(temp_file.path(), "0.66.5").await;
178
179 assert!(result.is_ok());
180 assert_eq!(result.unwrap(), upload_id);
181 }
182
183 #[tokio::test]
184 async fn test_upload_server_error() {
185 let (client, mock_server) = get_mock_client_server().await;
186
187 let sse_body = "data: {\"error\":\"Internal Server Error\"}\n\n";
189
190 Mock::given(method("POST"))
191 .and(path("/upload_project"))
192 .respond_with(
193 ResponseTemplate::new(200)
194 .insert_header("Content-Type", "text/event-stream")
195 .set_body_string(sse_body),
196 )
197 .mount(&mock_server)
198 .await;
199
200 let temp_file = NamedTempFile::new().unwrap();
201 fs::write(temp_file.path(), b"test content").unwrap();
202
203 let result = client.upload(temp_file.path(), "0.66.5").await;
204
205 assert!(result.is_err());
206 match result {
207 Err(Error::ApiResponseError { status, error }) => {
208 assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
209 assert_eq!(error, "{\"error\":\"Internal Server Error\"}");
210 }
211 _ => panic!("Expected ApiResponseError"),
212 }
213 }
214
215 #[tokio::test]
216 async fn test_publish_success() {
217 let (client, mock_server) = get_mock_client_server().await;
218
219 let publish_response = json!({
220 "name": "test_project",
221 "version": "1.0.0"
222 });
223
224 Mock::given(method("POST"))
225 .and(path("/publish"))
226 .respond_with(ResponseTemplate::new(200).set_body_json(&publish_response))
227 .mount(&mock_server)
228 .await;
229
230 let upload_id = Uuid::new_v4();
231
232 let result = client.publish(upload_id, "valid_auth_token").await;
233
234 assert!(result.is_ok());
235 let response = result.unwrap();
236 assert_eq!(response.name, "test_project");
237 assert_eq!(response.version.to_string(), "1.0.0");
238 }
239
240 #[tokio::test]
241 async fn test_publish_unauthorized() {
242 let (client, mock_server) = get_mock_client_server().await;
243
244 Mock::given(method("POST"))
245 .and(path("/publish"))
246 .respond_with(ResponseTemplate::new(401).set_body_json(json!({
247 "error": "Unauthorized"
248 })))
249 .mount(&mock_server)
250 .await;
251
252 let upload_id = Uuid::new_v4();
253
254 let result = client.publish(upload_id, "invalid_token").await;
255
256 assert!(result.is_err());
257 match result {
258 Err(Error::ApiResponseError { status, error }) => {
259 assert_eq!(status, StatusCode::UNAUTHORIZED);
260 assert_eq!(error, "Unauthorized");
261 }
262 _ => panic!("Expected ApiResponseError"),
263 }
264 }
265
266 #[tokio::test]
267 async fn test_publish_server_error() {
268 let (client, mock_server) = get_mock_client_server().await;
269
270 Mock::given(method("POST"))
271 .and(path("/publish"))
272 .respond_with(ResponseTemplate::new(500).set_body_json(json!({
273 "error": "Internal Server Error"
274 })))
275 .mount(&mock_server)
276 .await;
277
278 let upload_id = Uuid::new_v4();
279
280 let result = client.publish(upload_id, "valid_token").await;
281
282 assert!(result.is_err());
283 match result {
284 Err(Error::ApiResponseError { status, error }) => {
285 assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
286 assert_eq!(error, "Internal Server Error");
287 }
288 _ => panic!("Expected ApiResponseError"),
289 }
290 }
291}