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 let detailed_jobs = self.fetch_job_details(basic_jobs);
92
93 Ok(detailed_jobs)
94 }
95
96 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 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), {
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 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}