nsg-cli 0.1.0

CLI tool for the Neuroscience Gateway (NSG) BRAIN Initiative API
Documentation
use anyhow::{Context, Result};
use reqwest::blocking::{Client, multipart};
use std::path::Path;
use crate::config::Credentials;
use crate::models::*;

const NSG_BASE_URL: &str = "https://nsgr.sdsc.edu:8443/cipresrest/v1";

pub struct NsgClient {
    client: Client,
    credentials: Credentials,
    base_url: String,
}

impl NsgClient {
    pub fn new(credentials: Credentials) -> Result<Self> {
        let client = Client::builder()
            .timeout(std::time::Duration::from_secs(30))
            .build()
            .context("Failed to create HTTP client")?;

        Ok(Self {
            client,
            credentials,
            base_url: NSG_BASE_URL.to_string(),
        })
    }

    pub fn new_with_url(credentials: Credentials, base_url: String) -> Result<Self> {
        let client = Client::builder()
            .timeout(std::time::Duration::from_secs(30))
            .build()
            .context("Failed to create HTTP client")?;

        Ok(Self {
            client,
            credentials,
            base_url,
        })
    }

    fn build_request(&self, method: reqwest::Method, path: &str) -> reqwest::blocking::RequestBuilder {
        let url = format!("{}{}", self.base_url, path);
        self.client
            .request(method, &url)
            .basic_auth(&self.credentials.username, Some(&self.credentials.password))
            .header("cipres-appkey", &self.credentials.app_key)
    }

    pub fn test_connection(&self) -> Result<()> {
        let path = format!("/job/{}", self.credentials.username);
        let response = self
            .build_request(reqwest::Method::GET, &path)
            .send()
            .context("Failed to connect to NSG API")?;

        if !response.status().is_success() {
            anyhow::bail!(
                "Authentication failed: HTTP {} - Check your credentials",
                response.status()
            );
        }

        Ok(())
    }

    pub fn list_jobs(&self) -> Result<Vec<JobSummary>> {
        let path = format!("/job/{}", self.credentials.username);
        let response = self
            .build_request(reqwest::Method::GET, &path)
            .send()
            .context("Failed to fetch job list")?;

        if !response.status().is_success() {
            anyhow::bail!("Failed to list jobs: HTTP {}", response.status());
        }

        let body = response.text()?;
        parse_job_list(&body)
    }

    pub fn get_job_status(&self, job_url_or_id: &str) -> Result<JobStatus> {
        let path = if job_url_or_id.starts_with("http") {
            job_url_or_id
                .strip_prefix(&self.base_url)
                .context("Invalid job URL")?
                .to_string()
        } else if job_url_or_id.starts_with("/job/") {
            job_url_or_id.to_string()
        } else {
            format!("/job/{}/{}", self.credentials.username, job_url_or_id)
        };

        let response = self
            .build_request(reqwest::Method::GET, &path)
            .send()
            .context("Failed to fetch job status")?;

        if !response.status().is_success() {
            anyhow::bail!(
                "Failed to get job status: HTTP {}\nJob: {}",
                response.status(),
                job_url_or_id
            );
        }

        let body = response.text()?;
        parse_job_status(&body)
    }

    pub fn submit_job(&self, zip_path: &Path, tool: &str) -> Result<JobStatus> {
        let path = format!("/job/{}", self.credentials.username);

        let file_part = multipart::Part::file(zip_path)
            .context("Failed to read ZIP file")?
            .file_name(zip_path.file_name()
                .and_then(|n| n.to_str())
                .unwrap_or("job.zip")
                .to_string());

        let form = multipart::Form::new()
            .text("tool", tool.to_string())
            .part("input.infile_", file_part)
            .text("metadata.statusEmail", "true");

        let response = self
            .build_request(reqwest::Method::POST, &path)
            .multipart(form)
            .timeout(std::time::Duration::from_secs(60))
            .send()
            .context("Failed to submit job")?;

        if !response.status().is_success() {
            let status = response.status();
            let body = response.text().unwrap_or_default();
            anyhow::bail!(
                "Failed to submit job: HTTP {}\nResponse: {}",
                status,
                body
            );
        }

        let body = response.text()?;
        parse_job_status(&body)
    }

    pub fn download_results(&self, job_url_or_id: &str, output_dir: &Path) -> Result<Vec<DownloadedFile>> {
        let job_status = self.get_job_status(job_url_or_id)?;

        let results_url = job_status.results_uri
            .context("Job has no results URL - may not be completed yet")?;

        let results_path = results_url
            .strip_prefix(&self.base_url)
            .context("Invalid results URL")?;

        let response = self
            .build_request(reqwest::Method::GET, results_path)
            .send()
            .context("Failed to fetch results list")?;

        if !response.status().is_success() {
            anyhow::bail!("Failed to get results: HTTP {}", response.status());
        }

        let body = response.text()?;
        let output_files = parse_output_files(&body)?;

        std::fs::create_dir_all(output_dir)
            .context("Failed to create output directory")?;

        let mut downloaded = Vec::new();

        for file in output_files {
            let download_path = file.download_uri
                .strip_prefix(&self.base_url)
                .context("Invalid download URL")?;

            let output_path = output_dir.join(&file.filename);

            let mut response = self
                .build_request(reqwest::Method::GET, download_path)
                .send()
                .with_context(|| format!("Failed to download {}", file.filename))?;

            if !response.status().is_success() {
                anyhow::bail!(
                    "Failed to download {}: HTTP {}",
                    file.filename,
                    response.status()
                );
            }

            let mut dest = std::fs::File::create(&output_path)
                .with_context(|| format!("Failed to create {}", output_path.display()))?;

            response.copy_to(&mut dest)
                .with_context(|| format!("Failed to write {}", file.filename))?;

            downloaded.push(DownloadedFile {
                filename: file.filename,
                path: output_path,
                size: file.size,
            });
        }

        Ok(downloaded)
    }
}