Skip to main content

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
8#[cfg(feature = "parallel")]
9use rayon::prelude::*;
10
11const NSG_BASE_URL: &str = "https://nsgr.sdsc.edu:8443/cipresrest/v1";
12
13pub struct NsgClient {
14    client: Client,
15    credentials: Credentials,
16    base_url: String,
17}
18
19impl NsgClient {
20    pub fn new(credentials: Credentials) -> Result<Self> {
21        let client = Client::builder()
22            .timeout(std::time::Duration::from_secs(30))
23            .build()
24            .context("Failed to create HTTP client")?;
25
26        Ok(Self {
27            client,
28            credentials,
29            base_url: NSG_BASE_URL.to_string(),
30        })
31    }
32
33    pub fn new_with_url(credentials: Credentials, base_url: String) -> Result<Self> {
34        let client = Client::builder()
35            .timeout(std::time::Duration::from_secs(30))
36            .build()
37            .context("Failed to create HTTP client")?;
38
39        Ok(Self {
40            client,
41            credentials,
42            base_url,
43        })
44    }
45
46    fn build_request(
47        &self,
48        method: reqwest::Method,
49        path: &str,
50    ) -> reqwest::blocking::RequestBuilder {
51        let url = format!("{}{}", self.base_url, path);
52        self.client
53            .request(method, &url)
54            .basic_auth(&self.credentials.username, Some(&self.credentials.password))
55            .header("cipres-appkey", &self.credentials.app_key)
56    }
57
58    pub fn test_connection(&self) -> Result<()> {
59        let path = format!("/job/{}", self.credentials.username);
60        let response = self
61            .build_request(reqwest::Method::GET, &path)
62            .send()
63            .context("Failed to connect to NSG API")?;
64
65        if !response.status().is_success() {
66            anyhow::bail!(
67                "Authentication failed: HTTP {} - Check your credentials",
68                response.status()
69            );
70        }
71
72        Ok(())
73    }
74
75    pub fn list_jobs(&self) -> Result<Vec<JobSummary>> {
76        let path = format!("/job/{}", self.credentials.username);
77        let response = self
78            .build_request(reqwest::Method::GET, &path)
79            .send()
80            .context("Failed to fetch job list")?;
81
82        if !response.status().is_success() {
83            anyhow::bail!("Failed to list jobs: HTTP {}", response.status());
84        }
85
86        let body = response.text()?;
87        let basic_jobs = parse_job_list(&body)?;
88
89        // Fetch detailed status for each job to populate all fields
90        // Use parallel or sequential iteration based on feature flag
91        let detailed_jobs = self.fetch_job_details(basic_jobs);
92
93        Ok(detailed_jobs)
94    }
95
96    // Helper method to fetch job details with conditional parallel/sequential processing
97    fn fetch_job_details(&self, basic_jobs: Vec<JobSummary>) -> Vec<JobSummary> {
98        #[cfg(feature = "parallel")]
99        {
100            use std::sync::Mutex;
101            let jobs = Mutex::new(Vec::new());
102
103            basic_jobs.par_iter().for_each(|basic_job| {
104                let summary = self.convert_to_detailed_summary(basic_job);
105                jobs.lock().unwrap().push(summary);
106            });
107
108            jobs.into_inner().unwrap()
109        }
110
111        #[cfg(not(feature = "parallel"))]
112        {
113            basic_jobs
114                .into_iter()
115                .map(|basic_job| self.convert_to_detailed_summary(&basic_job))
116                .collect()
117        }
118    }
119
120    // Convert a basic job summary to a detailed one by fetching status
121    fn convert_to_detailed_summary(&self, basic_job: &JobSummary) -> JobSummary {
122        match self.get_job_status(&basic_job.url) {
123            Ok(status) => JobSummary {
124                job_id: status.job_id,
125                url: basic_job.url.clone(),
126                tool: status.tool_id,
127                job_stage: Some(status.job_stage),
128                failed: status.failed,
129                date_submitted: status.date_submitted,
130                date_completed: status.date_completed,
131            },
132            Err(_) => basic_job.clone(),
133        }
134    }
135
136    pub fn get_job_status(&self, job_url_or_id: &str) -> Result<JobStatus> {
137        let path = if job_url_or_id.starts_with("http") {
138            job_url_or_id
139                .strip_prefix(&self.base_url)
140                .context("Invalid job URL")?
141                .to_string()
142        } else if job_url_or_id.starts_with("/job/") {
143            job_url_or_id.to_string()
144        } else {
145            format!("/job/{}/{}", self.credentials.username, job_url_or_id)
146        };
147
148        let response = self
149            .build_request(reqwest::Method::GET, &path)
150            .send()
151            .context("Failed to fetch job status")?;
152
153        if !response.status().is_success() {
154            anyhow::bail!(
155                "Failed to get job status: HTTP {}\nJob: {}",
156                response.status(),
157                job_url_or_id
158            );
159        }
160
161        let body = response.text()?;
162        parse_job_status(&body)
163    }
164
165    pub fn submit_job(&self, zip_path: &Path, tool: &str) -> Result<JobStatus> {
166        let path = format!("/job/{}", self.credentials.username);
167
168        let file_part = multipart::Part::file(zip_path)
169            .context("Failed to read ZIP file")?
170            .file_name(
171                zip_path
172                    .file_name()
173                    .and_then(|n| n.to_str())
174                    .unwrap_or("job.zip")
175                    .to_string(),
176            );
177
178        let form = multipart::Form::new()
179            .text("tool", tool.to_string())
180            .part("input.infile_", file_part)
181            .text("metadata.statusEmail", "true");
182
183        let response = self
184            .build_request(reqwest::Method::POST, &path)
185            .multipart(form)
186            .timeout(std::time::Duration::from_secs(60))
187            .send()
188            .context("Failed to submit job")?;
189
190        if !response.status().is_success() {
191            let status = response.status();
192            let body = response.text().unwrap_or_default();
193            anyhow::bail!("Failed to submit job: HTTP {}\nResponse: {}", status, body);
194        }
195
196        let body = response.text()?;
197        parse_job_status(&body)
198    }
199
200    pub fn download_results<F>(
201        &self,
202        job_url_or_id: &str,
203        output_dir: &Path,
204        mut progress_callback: F,
205    ) -> Result<Vec<DownloadedFile>>
206    where
207        F: FnMut(&str, u64, u64), // (filename, bytes_downloaded, total_bytes)
208    {
209        let job_status = self.get_job_status(job_url_or_id)?;
210
211        let results_url = job_status
212            .results_uri
213            .context("Job has no results URL - may not be completed yet")?;
214
215        let results_path = results_url
216            .strip_prefix(&self.base_url)
217            .context("Invalid results URL")?;
218
219        let response = self
220            .build_request(reqwest::Method::GET, results_path)
221            .send()
222            .context("Failed to fetch results list")?;
223
224        if !response.status().is_success() {
225            anyhow::bail!("Failed to get results: HTTP {}", response.status());
226        }
227
228        let body = response.text()?;
229        let output_files = parse_output_files(&body)?;
230
231        std::fs::create_dir_all(output_dir).context("Failed to create output directory")?;
232
233        let mut downloaded = Vec::new();
234
235        for file in output_files {
236            let download_path = file
237                .download_uri
238                .strip_prefix(&self.base_url)
239                .context("Invalid download URL")?;
240
241            let output_path = output_dir.join(&file.filename);
242
243            let mut response = self
244                .build_request(reqwest::Method::GET, download_path)
245                .send()
246                .with_context(|| format!("Failed to download {}", file.filename))?;
247
248            if !response.status().is_success() {
249                anyhow::bail!(
250                    "Failed to download {}: HTTP {}",
251                    file.filename,
252                    response.status()
253                );
254            }
255
256            let mut dest = std::fs::File::create(&output_path)
257                .with_context(|| format!("Failed to create {}", output_path.display()))?;
258
259            // Download with progress tracking
260            let total_size = file.size;
261            let mut downloaded_bytes = 0u64;
262            let mut buffer = [0u8; 8192];
263
264            loop {
265                let bytes_read = response
266                    .read(&mut buffer)
267                    .with_context(|| format!("Failed to read from {}", file.filename))?;
268
269                if bytes_read == 0 {
270                    break;
271                }
272
273                dest.write_all(&buffer[..bytes_read])
274                    .with_context(|| format!("Failed to write to {}", file.filename))?;
275
276                downloaded_bytes += bytes_read as u64;
277                progress_callback(&file.filename, downloaded_bytes, total_size);
278            }
279
280            downloaded.push(DownloadedFile {
281                filename: file.filename,
282                path: output_path,
283                size: file.size,
284            });
285        }
286
287        Ok(downloaded)
288    }
289}