nsg_cli/
client.rs

1use crate::config::Credentials;
2use crate::models::*;
3use anyhow::{Context, Result};
4use reqwest::blocking::{multipart, Client};
5use std::io::{Read, Write};
6use std::path::Path;
7
8const NSG_BASE_URL: &str = "https://nsgr.sdsc.edu:8443/cipresrest/v1";
9
10pub struct NsgClient {
11    client: Client,
12    credentials: Credentials,
13    base_url: String,
14}
15
16impl NsgClient {
17    pub fn new(credentials: Credentials) -> Result<Self> {
18        let client = Client::builder()
19            .timeout(std::time::Duration::from_secs(30))
20            .build()
21            .context("Failed to create HTTP client")?;
22
23        Ok(Self {
24            client,
25            credentials,
26            base_url: NSG_BASE_URL.to_string(),
27        })
28    }
29
30    pub fn new_with_url(credentials: Credentials, base_url: String) -> Result<Self> {
31        let client = Client::builder()
32            .timeout(std::time::Duration::from_secs(30))
33            .build()
34            .context("Failed to create HTTP client")?;
35
36        Ok(Self {
37            client,
38            credentials,
39            base_url,
40        })
41    }
42
43    fn build_request(
44        &self,
45        method: reqwest::Method,
46        path: &str,
47    ) -> reqwest::blocking::RequestBuilder {
48        let url = format!("{}{}", self.base_url, path);
49        self.client
50            .request(method, &url)
51            .basic_auth(&self.credentials.username, Some(&self.credentials.password))
52            .header("cipres-appkey", &self.credentials.app_key)
53    }
54
55    pub fn test_connection(&self) -> Result<()> {
56        let path = format!("/job/{}", self.credentials.username);
57        let response = self
58            .build_request(reqwest::Method::GET, &path)
59            .send()
60            .context("Failed to connect to NSG API")?;
61
62        if !response.status().is_success() {
63            anyhow::bail!(
64                "Authentication failed: HTTP {} - Check your credentials",
65                response.status()
66            );
67        }
68
69        Ok(())
70    }
71
72    pub fn list_jobs(&self) -> Result<Vec<JobSummary>> {
73        let path = format!("/job/{}", self.credentials.username);
74        let response = self
75            .build_request(reqwest::Method::GET, &path)
76            .send()
77            .context("Failed to fetch job list")?;
78
79        if !response.status().is_success() {
80            anyhow::bail!("Failed to list jobs: HTTP {}", response.status());
81        }
82
83        let body = response.text()?;
84        parse_job_list(&body)
85    }
86
87    pub fn get_job_status(&self, job_url_or_id: &str) -> Result<JobStatus> {
88        let path = if job_url_or_id.starts_with("http") {
89            job_url_or_id
90                .strip_prefix(&self.base_url)
91                .context("Invalid job URL")?
92                .to_string()
93        } else if job_url_or_id.starts_with("/job/") {
94            job_url_or_id.to_string()
95        } else {
96            format!("/job/{}/{}", self.credentials.username, job_url_or_id)
97        };
98
99        let response = self
100            .build_request(reqwest::Method::GET, &path)
101            .send()
102            .context("Failed to fetch job status")?;
103
104        if !response.status().is_success() {
105            anyhow::bail!(
106                "Failed to get job status: HTTP {}\nJob: {}",
107                response.status(),
108                job_url_or_id
109            );
110        }
111
112        let body = response.text()?;
113        parse_job_status(&body)
114    }
115
116    pub fn submit_job(&self, zip_path: &Path, tool: &str) -> Result<JobStatus> {
117        let path = format!("/job/{}", self.credentials.username);
118
119        let file_part = multipart::Part::file(zip_path)
120            .context("Failed to read ZIP file")?
121            .file_name(
122                zip_path
123                    .file_name()
124                    .and_then(|n| n.to_str())
125                    .unwrap_or("job.zip")
126                    .to_string(),
127            );
128
129        let form = multipart::Form::new()
130            .text("tool", tool.to_string())
131            .part("input.infile_", file_part)
132            .text("metadata.statusEmail", "true");
133
134        let response = self
135            .build_request(reqwest::Method::POST, &path)
136            .multipart(form)
137            .timeout(std::time::Duration::from_secs(60))
138            .send()
139            .context("Failed to submit job")?;
140
141        if !response.status().is_success() {
142            let status = response.status();
143            let body = response.text().unwrap_or_default();
144            anyhow::bail!("Failed to submit job: HTTP {}\nResponse: {}", status, body);
145        }
146
147        let body = response.text()?;
148        parse_job_status(&body)
149    }
150
151    pub fn download_results<F>(
152        &self,
153        job_url_or_id: &str,
154        output_dir: &Path,
155        mut progress_callback: F,
156    ) -> Result<Vec<DownloadedFile>>
157    where
158        F: FnMut(&str, u64, u64), // (filename, bytes_downloaded, total_bytes)
159    {
160        let job_status = self.get_job_status(job_url_or_id)?;
161
162        let results_url = job_status
163            .results_uri
164            .context("Job has no results URL - may not be completed yet")?;
165
166        let results_path = results_url
167            .strip_prefix(&self.base_url)
168            .context("Invalid results URL")?;
169
170        let response = self
171            .build_request(reqwest::Method::GET, results_path)
172            .send()
173            .context("Failed to fetch results list")?;
174
175        if !response.status().is_success() {
176            anyhow::bail!("Failed to get results: HTTP {}", response.status());
177        }
178
179        let body = response.text()?;
180        let output_files = parse_output_files(&body)?;
181
182        std::fs::create_dir_all(output_dir).context("Failed to create output directory")?;
183
184        let mut downloaded = Vec::new();
185
186        for file in output_files {
187            let download_path = file
188                .download_uri
189                .strip_prefix(&self.base_url)
190                .context("Invalid download URL")?;
191
192            let output_path = output_dir.join(&file.filename);
193
194            let mut response = self
195                .build_request(reqwest::Method::GET, download_path)
196                .send()
197                .with_context(|| format!("Failed to download {}", file.filename))?;
198
199            if !response.status().is_success() {
200                anyhow::bail!(
201                    "Failed to download {}: HTTP {}",
202                    file.filename,
203                    response.status()
204                );
205            }
206
207            let mut dest = std::fs::File::create(&output_path)
208                .with_context(|| format!("Failed to create {}", output_path.display()))?;
209
210            // Download with progress tracking
211            let total_size = file.size;
212            let mut downloaded_bytes = 0u64;
213            let mut buffer = [0u8; 8192];
214
215            loop {
216                let bytes_read = response
217                    .read(&mut buffer)
218                    .with_context(|| format!("Failed to read from {}", file.filename))?;
219
220                if bytes_read == 0 {
221                    break;
222                }
223
224                dest.write_all(&buffer[..bytes_read])
225                    .with_context(|| format!("Failed to write to {}", file.filename))?;
226
227                downloaded_bytes += bytes_read as u64;
228                progress_callback(&file.filename, downloaded_bytes, total_size);
229            }
230
231            downloaded.push(DownloadedFile {
232                filename: file.filename,
233                path: output_path,
234                size: file.size,
235            });
236        }
237
238        Ok(downloaded)
239    }
240}