forc_publish/
forc_pub_client.rs

1use 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/// The publish request.
12#[derive(Serialize, Debug)]
13pub struct PublishRequest {
14    pub upload_id: Uuid,
15}
16
17/// The publish response.
18#[derive(Serialize, Deserialize, Debug)]
19pub struct PublishResponse {
20    pub name: String,
21    pub version: Version,
22}
23
24/// The response to an upload_project request.
25#[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    /// Uploads the given file to the server
42    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            // Process the SSE stream.
62            // The server sends events in the format: "data: <event>\n\n" or
63            // ": <event>\n\n" for keep-alive events.
64            // The first event is usually a progress event, and the last one contains the upload_id
65            // or an error message. If the stream is open for more than 60 seconds, it will be closed
66            // by the server, and we will return an HTTPError.
67            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                                    // Attempt to parse error from JSON
80                                    return Err(Error::ApiResponseError {
81                                        status: StatusCode::INTERNAL_SERVER_ERROR,
82                                        error: data.to_string(),
83                                    });
84                                } else {
85                                    // Print the event data, replacing the previous message.
86                                    print!("\r\x1b[2K  =>  {data}");
87                                    stdout().flush().unwrap();
88                                }
89                            }
90                            // else if event.starts_with(":") {
91                            // These are keep-alive events. Uncomment if you need to debug them.
92                            // println!("Keep-alive event: {}", event);
93                            // }
94                        }
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    /// Publishes the given upload_id to the registry
109    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        // Simulate SSE response with a progress event and a final upload_id event
157        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        // Create a temporary gzip file
174        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        // Simulate SSE error event
188        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}