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), {
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 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}