diff --git a/src/api.rs b/src/api.rs new file mode 100644 index 0000000..37e7845 --- /dev/null +++ b/src/api.rs @@ -0,0 +1,267 @@ +use crate::config; +use crate::util; +use crossterm::style::Stylize; +use serde::de::DeserializeOwned; + +pub struct ApiClient { + client: reqwest::blocking::Client, + api_key: String, + pub api_url: String, + workspace_id: Option, +} + +impl ApiClient { + /// Create a new API client. Loads config, validates auth. + /// Pass `workspace_id` for endpoints that require it, or `None` for workspace-less endpoints. + pub fn new(workspace_id: Option<&str>) -> Self { + let profile_config = match config::load("default") { + Ok(c) => c, + Err(e) => { + eprintln!("{e}"); + std::process::exit(1); + } + }; + + let api_key = match &profile_config.api_key { + Some(key) if key != "PLACEHOLDER" => key.clone(), + _ => { + eprintln!("error: not authenticated. Run 'hotdata auth' to log in."); + std::process::exit(1); + } + }; + + Self { + client: reqwest::blocking::Client::new(), + api_key, + api_url: profile_config.api_url.to_string(), + workspace_id: workspace_id.map(String::from), + } + } + + fn debug_headers(&self) -> Vec<(&str, String)> { + let masked = if self.api_key.len() > 4 { + format!("Bearer ...{}", &self.api_key[self.api_key.len()-4..]) + } else { + "Bearer ***".to_string() + }; + let mut headers = vec![("Authorization", masked)]; + if let Some(ref ws) = self.workspace_id { + headers.push(("X-Workspace-Id", ws.clone())); + } + headers + } + + fn log_request(&self, method: &str, url: &str, body: Option<&serde_json::Value>) { + let headers = self.debug_headers(); + let header_refs: Vec<(&str, &str)> = headers.iter().map(|(k, v)| (*k, v.as_str())).collect(); + util::debug_request(method, url, &header_refs, body); + } + + fn build_request(&self, method: reqwest::Method, url: &str) -> reqwest::blocking::RequestBuilder { + let mut req = self.client.request(method, url) + .header("Authorization", format!("Bearer {}", self.api_key)); + if let Some(ref ws) = self.workspace_id { + req = req.header("X-Workspace-Id", ws); + } + req + } + + /// GET request with query parameters, returns parsed response. + /// Parameters with `None` values are omitted. + pub fn get_with_params(&self, path: &str, params: &[(&str, Option)]) -> T { + let filtered: Vec<(&str, &String)> = params.iter() + .filter_map(|(k, v)| v.as_ref().map(|val| (*k, val))) + .collect(); + let url = format!("{}{path}", self.api_url); + self.log_request("GET", &url, None); + + let resp = match self.build_request(reqwest::Method::GET, &url).query(&filtered).send() { + Ok(r) => r, + Err(e) => { + eprintln!("error connecting to API: {e}"); + std::process::exit(1); + } + }; + + let (status, body) = util::debug_response(resp); + if !status.is_success() { + eprintln!("{}", util::api_error(body).red()); + std::process::exit(1); + } + + match serde_json::from_str(&body) { + Ok(v) => v, + Err(e) => { + eprintln!("error parsing response: {e}"); + std::process::exit(1); + } + } + } + + /// GET request, returns parsed response. + pub fn get(&self, path: &str) -> T { + let url = format!("{}{path}", self.api_url); + self.log_request("GET", &url, None); + + let resp = match self.build_request(reqwest::Method::GET, &url).send() { + Ok(r) => r, + Err(e) => { + eprintln!("error connecting to API: {e}"); + std::process::exit(1); + } + }; + + let (status, body) = util::debug_response(resp); + if !status.is_success() { + eprintln!("{}", util::api_error(body).red()); + std::process::exit(1); + } + + match serde_json::from_str(&body) { + Ok(v) => v, + Err(e) => { + eprintln!("error parsing response: {e}"); + std::process::exit(1); + } + } + } + + /// POST request with JSON body, returns parsed response. + pub fn post(&self, path: &str, body: &serde_json::Value) -> T { + let url = format!("{}{path}", self.api_url); + self.log_request("POST", &url, Some(body)); + + let resp = match self.build_request(reqwest::Method::POST, &url) + .json(body) + .send() + { + Ok(r) => r, + Err(e) => { + eprintln!("error connecting to API: {e}"); + std::process::exit(1); + } + }; + + let (status, resp_body) = util::debug_response(resp); + if !status.is_success() { + eprintln!("{}", util::api_error(resp_body).red()); + std::process::exit(1); + } + + match serde_json::from_str(&resp_body) { + Ok(v) => v, + Err(e) => { + eprintln!("error parsing response: {e}"); + std::process::exit(1); + } + } + } + + /// POST request with JSON body, exits on error, returns raw (status, body). + pub fn post_raw(&self, path: &str, body: &serde_json::Value) -> (reqwest::StatusCode, String) { + let url = format!("{}{path}", self.api_url); + self.log_request("POST", &url, Some(body)); + + let resp = match self.build_request(reqwest::Method::POST, &url) + .json(body) + .send() + { + Ok(r) => r, + Err(e) => { + eprintln!("error connecting to API: {e}"); + std::process::exit(1); + } + }; + + util::debug_response(resp) + } + + /// POST request with no body (e.g. execute endpoints), returns parsed response. + pub fn post_empty(&self, path: &str) -> T { + let url = format!("{}{path}", self.api_url); + self.log_request("POST", &url, None); + + let resp = match self.build_request(reqwest::Method::POST, &url).send() { + Ok(r) => r, + Err(e) => { + eprintln!("error connecting to API: {e}"); + std::process::exit(1); + } + }; + + let (status, resp_body) = util::debug_response(resp); + if !status.is_success() { + eprintln!("{}", util::api_error(resp_body).red()); + std::process::exit(1); + } + + match serde_json::from_str(&resp_body) { + Ok(v) => v, + Err(e) => { + eprintln!("error parsing response: {e}"); + std::process::exit(1); + } + } + } + + /// PUT request with JSON body, returns parsed response. + pub fn put(&self, path: &str, body: &serde_json::Value) -> T { + let url = format!("{}{path}", self.api_url); + self.log_request("PUT", &url, Some(body)); + + let resp = match self.build_request(reqwest::Method::PUT, &url) + .json(body) + .send() + { + Ok(r) => r, + Err(e) => { + eprintln!("error connecting to API: {e}"); + std::process::exit(1); + } + }; + + let (status, resp_body) = util::debug_response(resp); + if !status.is_success() { + eprintln!("{}", util::api_error(resp_body).red()); + std::process::exit(1); + } + + match serde_json::from_str(&resp_body) { + Ok(v) => v, + Err(e) => { + eprintln!("error parsing response: {e}"); + std::process::exit(1); + } + } + } + + /// POST with a custom request body (for file uploads). Returns raw status and body. + pub fn post_body( + &self, + path: &str, + content_type: &str, + reader: R, + content_length: Option, + ) -> (reqwest::StatusCode, String) { + let url = format!("{}{path}", self.api_url); + self.log_request("POST", &url, None); + + let mut req = self.build_request(reqwest::Method::POST, &url) + .header("Content-Type", content_type); + + if let Some(len) = content_length { + req = req.header("Content-Length", len); + } + + let resp = match req.body(reqwest::blocking::Body::new(reader)).send() { + Ok(r) => r, + Err(e) => { + eprintln!("error connecting to API: {e}"); + std::process::exit(1); + } + }; + + util::debug_response(resp) + } + +} diff --git a/src/command.rs b/src/command.rs index 486888c..01d10b6 100644 --- a/src/command.rs +++ b/src/command.rs @@ -14,12 +14,12 @@ pub enum Commands { id: Option, /// Workspace ID (defaults to first workspace from login) - #[arg(long, global = true)] + #[arg(long, short = 'w', global = true)] workspace_id: Option, /// Output format (used with dataset ID) - #[arg(long, default_value = "table", value_parser = ["table", "json", "yaml"])] - format: String, + #[arg(long = "output", short = 'o', default_value = "table", value_parser = ["table", "json", "yaml"])] + output: String, #[command(subcommand)] command: Option, @@ -31,7 +31,7 @@ pub enum Commands { sql: String, /// Workspace ID (defaults to first workspace from login) - #[arg(long)] + #[arg(long, short = 'w')] workspace_id: Option, /// Scope query to a specific connection @@ -39,8 +39,8 @@ pub enum Commands { connection: Option, /// Output format - #[arg(long, default_value = "table", value_parser = ["table", "json", "csv"])] - format: String, + #[arg(long = "output", short = 'o', default_value = "table", value_parser = ["table", "json", "csv"])] + output: String, }, /// Manage workspaces @@ -52,7 +52,7 @@ pub enum Commands { /// Manage workspace connections Connections { /// Workspace ID (defaults to first workspace from login) - #[arg(long, global = true)] + #[arg(long, short = 'w', global = true)] workspace_id: Option, #[command(subcommand)] @@ -77,12 +77,12 @@ pub enum Commands { result_id: Option, /// Workspace ID (defaults to first workspace from login) - #[arg(long, global = true)] + #[arg(long, short = 'w', global = true)] workspace_id: Option, /// Output format - #[arg(long, default_value = "table", value_parser = ["table", "json", "csv"])] - format: String, + #[arg(long = "output", short = 'o', default_value = "table", value_parser = ["table", "json", "csv"])] + output: String, #[command(subcommand)] command: Option, @@ -94,12 +94,12 @@ pub enum Commands { id: Option, /// Workspace ID (defaults to first workspace from login) - #[arg(long, global = true)] + #[arg(long, short = 'w', global = true)] workspace_id: Option, /// Output format (used with job ID) - #[arg(long, default_value = "table", value_parser = ["table", "json", "yaml"])] - format: String, + #[arg(long = "output", short = 'o', default_value = "table", value_parser = ["table", "json", "yaml"])] + output: String, #[command(subcommand)] command: Option, @@ -108,7 +108,7 @@ pub enum Commands { /// Manage indexes on a table Indexes { /// Workspace ID (defaults to first workspace from login) - #[arg(long, global = true)] + #[arg(long, short = 'w', global = true)] workspace_id: Option, #[command(subcommand)] @@ -137,12 +137,12 @@ pub enum Commands { limit: u32, /// Workspace ID (defaults to first workspace from login) - #[arg(long)] + #[arg(long, short = 'w')] workspace_id: Option, /// Output format - #[arg(long, default_value = "table", value_parser = ["table", "json", "csv"])] - format: String, + #[arg(long = "output", short = 'o', default_value = "table", value_parser = ["table", "json", "csv"])] + output: String, }, /// Manage saved queries @@ -151,8 +151,8 @@ pub enum Commands { id: Option, /// Output format (used with query ID) - #[arg(long, default_value = "table", value_parser = ["table", "json", "yaml"])] - format: String, + #[arg(long = "output", short = 'o', default_value = "table", value_parser = ["table", "json", "yaml"])] + output: String, #[command(subcommand)] command: Option, @@ -197,7 +197,7 @@ pub enum IndexesCommands { /// List indexes on a table List { /// Connection ID - #[arg(long)] + #[arg(long, short = 'c')] connection_id: String, /// Schema name @@ -209,14 +209,14 @@ pub enum IndexesCommands { table: String, /// Output format - #[arg(long, default_value = "table", value_parser = ["table", "json", "yaml"])] - format: String, + #[arg(long = "output", short = 'o', default_value = "table", value_parser = ["table", "json", "yaml"])] + output: String, }, /// Create an index on a table Create { /// Connection ID - #[arg(long)] + #[arg(long, short = 'c')] connection_id: String, /// Schema name @@ -274,8 +274,8 @@ pub enum JobsCommands { offset: Option, /// Output format - #[arg(long, default_value = "table", value_parser = ["table", "json", "yaml"])] - format: String, + #[arg(long = "output", short = 'o', default_value = "table", value_parser = ["table", "json", "yaml"])] + output: String, }, } @@ -292,8 +292,8 @@ pub enum DatasetsCommands { offset: Option, /// Output format - #[arg(long, default_value = "table", value_parser = ["table", "json", "yaml"])] - format: String, + #[arg(long = "output", short = 'o', default_value = "table", value_parser = ["table", "json", "yaml"])] + output: String, }, /// Create a new dataset from a file, piped stdin, upload ID, or SQL query @@ -338,8 +338,8 @@ pub enum WorkspaceCommands { /// List all workspaces List { /// Output format - #[arg(long, default_value = "table", value_parser = ["table", "json", "yaml"])] - format: String, + #[arg(long = "output", short = 'o', default_value = "table", value_parser = ["table", "json", "yaml"])] + output: String, }, /// Set the default workspace @@ -351,12 +351,12 @@ pub enum WorkspaceCommands { /// Get details for a workspace Get { /// Workspace ID (defaults to first workspace from login) - #[arg(long)] + #[arg(long, short = 'w')] workspace_id: Option, /// Output format - #[arg(long, default_value = "yaml", value_parser = ["table", "json", "yaml"])] - format: String, + #[arg(long = "output", short = 'o', default_value = "yaml", value_parser = ["table", "json", "yaml"])] + output: String, }, /// Create a new workspace @@ -374,14 +374,14 @@ pub enum WorkspaceCommands { organization_id: String, /// Output format - #[arg(long, default_value = "yaml", value_parser = ["table", "json", "yaml"])] - format: String, + #[arg(long = "output", short = 'o', default_value = "yaml", value_parser = ["table", "json", "yaml"])] + output: String, }, /// Update an existing workspace Update { /// Workspace ID (defaults to first workspace from login) - #[arg(long)] + #[arg(long, short = 'w')] workspace_id: Option, /// New workspace name @@ -393,8 +393,8 @@ pub enum WorkspaceCommands { description: Option, /// Output format - #[arg(long, default_value = "yaml", value_parser = ["table", "json", "yaml"])] - format: String, + #[arg(long = "output", short = 'o', default_value = "yaml", value_parser = ["table", "json", "yaml"])] + output: String, }, } @@ -406,8 +406,8 @@ pub enum ConnectionsCreateCommands { name: Option, /// Output format - #[arg(long, default_value = "table", value_parser = ["table", "json", "yaml"])] - format: String, + #[arg(long = "output", short = 'o', default_value = "table", value_parser = ["table", "json", "yaml"])] + output: String, }, } @@ -419,8 +419,8 @@ pub enum ConnectionsCommands { /// List all connections for a workspace List { /// Output format - #[arg(long, default_value = "table", value_parser = ["table", "json", "yaml"])] - format: String, + #[arg(long = "output", short = 'o', default_value = "table", value_parser = ["table", "json", "yaml"])] + output: String, }, /// Get details for a specific connection @@ -429,8 +429,8 @@ pub enum ConnectionsCommands { connection_id: String, /// Output format - #[arg(long, default_value = "yaml", value_parser = ["table", "json", "yaml"])] - format: String, + #[arg(long = "output", short = 'o', default_value = "yaml", value_parser = ["table", "json", "yaml"])] + output: String, }, /// Create a new connection, or list/inspect available connection types @@ -451,8 +451,8 @@ pub enum ConnectionsCommands { config: Option, /// Output format - #[arg(long, default_value = "table", value_parser = ["table", "json", "yaml"])] - format: String, + #[arg(long = "output", short = 'o', default_value = "table", value_parser = ["table", "json", "yaml"])] + output: String, }, /// Update a connection in a workspace @@ -473,8 +473,8 @@ pub enum ConnectionsCommands { config: Option, /// Output format - #[arg(long, default_value = "yaml", value_parser = ["table", "json", "yaml"])] - format: String, + #[arg(long = "output", short = 'o', default_value = "yaml", value_parser = ["table", "json", "yaml"])] + output: String, }, /// Refresh a connection's schema @@ -515,8 +515,8 @@ pub enum ResultsCommands { offset: Option, /// Output format - #[arg(long, default_value = "table", value_parser = ["table", "json", "yaml"])] - format: String, + #[arg(long = "output", short = 'o', default_value = "table", value_parser = ["table", "json", "yaml"])] + output: String, }, } @@ -533,8 +533,8 @@ pub enum QueriesCommands { offset: Option, /// Output format - #[arg(long, default_value = "table", value_parser = ["table", "json", "yaml"])] - format: String, + #[arg(long = "output", short = 'o', default_value = "table", value_parser = ["table", "json", "yaml"])] + output: String, }, /// Create a new saved query @@ -556,8 +556,8 @@ pub enum QueriesCommands { tags: Option, /// Output format - #[arg(long, default_value = "table", value_parser = ["table", "json", "yaml"])] - format: String, + #[arg(long = "output", short = 'o', default_value = "table", value_parser = ["table", "json", "yaml"])] + output: String, }, /// Execute a saved query @@ -566,8 +566,8 @@ pub enum QueriesCommands { id: String, /// Output format - #[arg(long, default_value = "table", value_parser = ["table", "json", "csv"])] - format: String, + #[arg(long = "output", short = 'o', default_value = "table", value_parser = ["table", "json", "csv"])] + output: String, }, /// Update a saved query @@ -600,8 +600,8 @@ pub enum QueriesCommands { table_size: Option, /// Output format - #[arg(long, default_value = "table", value_parser = ["table", "json", "yaml"])] - format: String, + #[arg(long = "output", short = 'o', default_value = "table", value_parser = ["table", "json", "yaml"])] + output: String, }, } @@ -610,11 +610,11 @@ pub enum TablesCommands { /// List all tables in a workspace List { /// Workspace ID (defaults to first workspace from login) - #[arg(long)] + #[arg(long, short = 'w')] workspace_id: Option, /// Filter by connection ID (also enables column output) - #[arg(long)] + #[arg(long, short = 'c')] connection_id: Option, /// Filter by schema name (supports % wildcards) @@ -634,7 +634,7 @@ pub enum TablesCommands { cursor: Option, /// Output format - #[arg(long, default_value = "table", value_parser = ["table", "json", "yaml"])] - format: String, + #[arg(long = "output", short = 'o', default_value = "table", value_parser = ["table", "json", "yaml"])] + output: String, }, } diff --git a/src/connections.rs b/src/connections.rs index 2964d77..8168c9f 100644 --- a/src/connections.rs +++ b/src/connections.rs @@ -1,4 +1,4 @@ -use crate::config; +use crate::api::ApiClient; use serde::{Deserialize, Serialize}; #[derive(Deserialize, Serialize)] @@ -21,51 +21,8 @@ struct ConnectionTypeDetail { } pub fn types_list(workspace_id: &str, format: &str) { - let profile_config = match config::load("default") { - Ok(c) => c, - Err(e) => { - eprintln!("{e}"); - std::process::exit(1); - } - }; - - let api_key = match &profile_config.api_key { - Some(key) if key != "PLACEHOLDER" => key.clone(), - _ => { - eprintln!("error: not authenticated. Run 'hotdata auth' to log in."); - std::process::exit(1); - } - }; - - let url = format!("{}/connection-types", profile_config.api_url); - let client = reqwest::blocking::Client::new(); - - let resp = match client - .get(&url) - .header("Authorization", format!("Bearer {api_key}")) - .header("X-Workspace-Id", workspace_id) - .send() - { - Ok(r) => r, - Err(e) => { - eprintln!("error connecting to API: {e}"); - std::process::exit(1); - } - }; - - if !resp.status().is_success() { - use crossterm::style::Stylize; - eprintln!("{}", crate::util::api_error(resp.text().unwrap_or_default()).red()); - std::process::exit(1); - } - - let body: ListConnectionTypesResponse = match resp.json() { - Ok(b) => b, - Err(e) => { - eprintln!("error parsing response: {e}"); - std::process::exit(1); - } - }; + let api = ApiClient::new(Some(workspace_id)); + let body: ListConnectionTypesResponse = api.get("/connection-types"); match format { "json" => println!("{}", serde_json::to_string_pretty(&body.connection_types).unwrap()), @@ -86,51 +43,8 @@ pub fn types_list(workspace_id: &str, format: &str) { } pub fn types_get(workspace_id: &str, name: &str, format: &str) { - let profile_config = match config::load("default") { - Ok(c) => c, - Err(e) => { - eprintln!("{e}"); - std::process::exit(1); - } - }; - - let api_key = match &profile_config.api_key { - Some(key) if key != "PLACEHOLDER" => key.clone(), - _ => { - eprintln!("error: not authenticated. Run 'hotdata auth' to log in."); - std::process::exit(1); - } - }; - - let url = format!("{}/connection-types/{name}", profile_config.api_url); - let client = reqwest::blocking::Client::new(); - - let resp = match client - .get(&url) - .header("Authorization", format!("Bearer {api_key}")) - .header("X-Workspace-Id", workspace_id) - .send() - { - Ok(r) => r, - Err(e) => { - eprintln!("error connecting to API: {e}"); - std::process::exit(1); - } - }; - - if !resp.status().is_success() { - use crossterm::style::Stylize; - eprintln!("{}", crate::util::api_error(resp.text().unwrap_or_default()).red()); - std::process::exit(1); - } - - let detail: ConnectionTypeDetail = match resp.json() { - Ok(d) => d, - Err(e) => { - eprintln!("error parsing response: {e}"); - std::process::exit(1); - } - }; + let api = ApiClient::new(Some(workspace_id)); + let detail: ConnectionTypeDetail = api.get(&format!("/connection-types/{name}")); match format { "json" => println!("{}", serde_json::to_string_pretty(&detail).unwrap()), @@ -168,22 +82,6 @@ pub fn create( config: &str, format: &str, ) { - let profile_config = match crate::config::load("default") { - Ok(c) => c, - Err(e) => { - eprintln!("{e}"); - std::process::exit(1); - } - }; - - let api_key = match &profile_config.api_key { - Some(key) if key != "PLACEHOLDER" => key.clone(), - _ => { - eprintln!("error: not authenticated. Run 'hotdata auth' to log in."); - std::process::exit(1); - } - }; - let config_value: serde_json::Value = match serde_json::from_str(config) { Ok(v) => v, Err(e) => { @@ -198,28 +96,7 @@ pub fn create( "config": config_value, }); - let url = format!("{}/connections", profile_config.api_url); - let client = reqwest::blocking::Client::new(); - - let resp = match client - .post(&url) - .header("Authorization", format!("Bearer {api_key}")) - .header("X-Workspace-Id", workspace_id) - .json(&body) - .send() - { - Ok(r) => r, - Err(e) => { - eprintln!("error connecting to API: {e}"); - std::process::exit(1); - } - }; - - if !resp.status().is_success() { - use crossterm::style::Stylize; - eprintln!("{}", crate::util::api_error(resp.text().unwrap_or_default()).red()); - std::process::exit(1); - } + let api = ApiClient::new(Some(workspace_id)); #[derive(Deserialize, Serialize)] struct CreateResponse { @@ -231,13 +108,7 @@ pub fn create( discovery_error: Option, } - let result: CreateResponse = match resp.json() { - Ok(v) => v, - Err(e) => { - eprintln!("error parsing response: {e}"); - std::process::exit(1); - } - }; + let result: CreateResponse = api.post("/connections", &body); match format { "json" => println!("{}", serde_json::to_string_pretty(&result).unwrap()), @@ -261,51 +132,8 @@ pub fn create( } pub fn list(workspace_id: &str, format: &str) { - let profile_config = match config::load("default") { - Ok(c) => c, - Err(e) => { - eprintln!("{e}"); - std::process::exit(1); - } - }; - - let api_key = match &profile_config.api_key { - Some(key) if key != "PLACEHOLDER" => key.clone(), - _ => { - eprintln!("error: not authenticated. Run 'hotdata auth' to log in."); - std::process::exit(1); - } - }; - - let url = format!("{}/connections", profile_config.api_url); - let client = reqwest::blocking::Client::new(); - - let resp = match client - .get(&url) - .header("Authorization", format!("Bearer {api_key}")) - .header("X-Workspace-Id", workspace_id) - .send() - { - Ok(r) => r, - Err(e) => { - eprintln!("error connecting to API: {e}"); - std::process::exit(1); - } - }; - - if !resp.status().is_success() { - use crossterm::style::Stylize; - eprintln!("{}", crate::util::api_error(resp.text().unwrap_or_default()).red()); - std::process::exit(1); - } - - let body: ListResponse = match resp.json() { - Ok(b) => b, - Err(e) => { - eprintln!("error parsing response: {e}"); - std::process::exit(1); - } - }; + let api = ApiClient::new(Some(workspace_id)); + let body: ListResponse = api.get("/connections"); match format { "json" => { @@ -330,47 +158,17 @@ pub fn list(workspace_id: &str, format: &str) { } pub fn refresh(workspace_id: &str, connection_id: &str) { - let profile_config = match config::load("default") { - Ok(c) => c, - Err(e) => { - eprintln!("{e}"); - std::process::exit(1); - } - }; - - let api_key = match &profile_config.api_key { - Some(key) if key != "PLACEHOLDER" => key.clone(), - _ => { - eprintln!("error: not authenticated. Run 'hotdata auth login' to log in."); - std::process::exit(1); - } - }; - let body = serde_json::json!({ "connection_id": connection_id, "data": false, }); - let url = format!("{}/refresh", profile_config.api_url); - let client = reqwest::blocking::Client::new(); - - let resp = match client - .post(&url) - .header("Authorization", format!("Bearer {api_key}")) - .header("X-Workspace-Id", workspace_id) - .json(&body) - .send() - { - Ok(r) => r, - Err(e) => { - eprintln!("error connecting to API: {e}"); - std::process::exit(1); - } - }; + let api = ApiClient::new(Some(workspace_id)); + let (status, resp_body) = api.post_raw("/refresh", &body); - if !resp.status().is_success() { + if !status.is_success() { use crossterm::style::Stylize; - eprintln!("{}", crate::util::api_error(resp.text().unwrap_or_default()).red()); + eprintln!("{}", crate::util::api_error(resp_body).red()); std::process::exit(1); } diff --git a/src/connections_new.rs b/src/connections_new.rs index 91a8034..c0b9b34 100644 --- a/src/connections_new.rs +++ b/src/connections_new.rs @@ -2,6 +2,8 @@ use inquire::{Confirm, Password, Select, Text}; use inquire::validator::Validation; use serde_json::{Map, Number, Value}; +use crate::api::ApiClient; + // ── HTTP helpers ────────────────────────────────────────────────────────────── struct ConnectionTypeSummary { @@ -14,38 +16,8 @@ struct ConnectionTypeDetail { auth: Option, } -fn load_client() -> (reqwest::blocking::Client, String, String) { - let profile = match crate::config::load("default") { - Ok(c) => c, - Err(e) => { - eprintln!("{e}"); - std::process::exit(1); - } - }; - let api_key = match &profile.api_key { - Some(k) if k != "PLACEHOLDER" => k.clone(), - _ => { - eprintln!("error: not authenticated. Run 'hotdata auth' to log in."); - std::process::exit(1); - } - }; - (reqwest::blocking::Client::new(), api_key, profile.api_url.to_string()) -} - -fn fetch_types(workspace_id: &str) -> Vec { - let (client, api_key, api_url) = load_client(); - let url = format!("{api_url}/connection-types"); - let resp = client - .get(&url) - .header("Authorization", format!("Bearer {api_key}")) - .header("X-Workspace-Id", workspace_id) - .send() - .unwrap_or_else(|e| { eprintln!("error: {e}"); std::process::exit(1) }); - if !resp.status().is_success() { - eprintln!("{}", crate::util::api_error(resp.text().unwrap_or_default())); - std::process::exit(1); - } - let body: Value = resp.json().unwrap_or_else(|e| { eprintln!("error: {e}"); std::process::exit(1) }); +fn fetch_types(api: &ApiClient) -> Vec { + let body: Value = api.get("/connection-types"); body["connection_types"] .as_array() .unwrap_or(&vec![]) @@ -59,20 +31,8 @@ fn fetch_types(workspace_id: &str) -> Vec { .collect() } -fn fetch_detail(workspace_id: &str, name: &str) -> ConnectionTypeDetail { - let (client, api_key, api_url) = load_client(); - let url = format!("{api_url}/connection-types/{name}"); - let resp = client - .get(&url) - .header("Authorization", format!("Bearer {api_key}")) - .header("X-Workspace-Id", workspace_id) - .send() - .unwrap_or_else(|e| { eprintln!("error: {e}"); std::process::exit(1) }); - if !resp.status().is_success() { - eprintln!("{}", crate::util::api_error(resp.text().unwrap_or_default())); - std::process::exit(1); - } - let body: Value = resp.json().unwrap_or_else(|e| { eprintln!("error: {e}"); std::process::exit(1) }); +fn fetch_detail(api: &ApiClient, name: &str) -> ConnectionTypeDetail { + let body: Value = api.get(&format!("/connection-types/{name}")); ConnectionTypeDetail { config_schema: if body["config_schema"].is_null() { None } else { Some(body["config_schema"].clone()) }, auth: if body["auth"].is_null() { None } else { Some(body["auth"].clone()) }, @@ -255,8 +215,10 @@ fn walk_auth(schema: &Value) -> Map { // ── Entry point ─────────────────────────────────────────────────────────────── pub fn run(workspace_id: &str) { + let api = ApiClient::new(Some(workspace_id)); + // Phase 1: Select connection type - let types = fetch_types(workspace_id); + let types = fetch_types(&api); if types.is_empty() { eprintln!("error: no connection types available"); std::process::exit(1); @@ -271,7 +233,7 @@ pub fn run(workspace_id: &str) { let source_type = &names[idx]; // Phase 2: Fetch schema for selected type - let detail = fetch_detail(workspace_id, source_type); + let detail = fetch_detail(&api, source_type); // Phase 3: Connection name let conn_name = Text::new("Connection name:") @@ -290,28 +252,12 @@ pub fn run(workspace_id: &str) { } // Phase 6: Submit - let (client, api_key, api_url) = load_client(); let body = serde_json::json!({ "name": conn_name, "source_type": source_type, "config": Value::Object(config), }); - let url = format!("{api_url}/connections"); - let resp = client - .post(&url) - .header("Authorization", format!("Bearer {api_key}")) - .header("X-Workspace-Id", workspace_id) - .json(&body) - .send() - .unwrap_or_else(|e| { eprintln!("error connecting to API: {e}"); std::process::exit(1) }); - - if !resp.status().is_success() { - use crossterm::style::Stylize; - eprintln!("{}", crate::util::api_error(resp.text().unwrap_or_default()).red()); - std::process::exit(1); - } - #[derive(serde::Deserialize)] struct CreateResponse { id: String, @@ -322,8 +268,7 @@ pub fn run(workspace_id: &str) { discovery_error: Option, } - let result: CreateResponse = resp.json() - .unwrap_or_else(|e| { eprintln!("error parsing response: {e}"); std::process::exit(1) }); + let result: CreateResponse = api.post("/connections", &body); use crossterm::style::Stylize; println!("{}", "Connection created".green()); diff --git a/src/datasets.rs b/src/datasets.rs index e4b69b8..f866ded 100644 --- a/src/datasets.rs +++ b/src/datasets.rs @@ -1,4 +1,4 @@ -use crate::config; +use crate::api::ApiClient; use indicatif::{ProgressBar, ProgressStyle}; use serde::{Deserialize, Serialize}; use serde_json::json; @@ -111,48 +111,23 @@ fn make_progress_bar(total: u64) -> ProgressBar { } fn do_upload( - client: &reqwest::blocking::Client, - api_key: &str, - workspace_id: &str, - api_url: &str, + api: &ApiClient, content_type: &str, reader: R, pb: ProgressBar, content_length: Option, ) -> String { - let url = format!("{api_url}/files"); - - let mut req = client - .post(&url) - .header("Authorization", format!("Bearer {api_key}")) - .header("X-Workspace-Id", workspace_id) - .header("Content-Type", content_type); - - if let Some(len) = content_length { - req = req.header("Content-Length", len); - } - - let resp = match req - .body(reqwest::blocking::Body::new(reader)) - .send() - { - Ok(r) => r, - Err(e) => { - pb.finish_and_clear(); - eprintln!("error uploading: {e}"); - std::process::exit(1); - } - }; + let (status, resp_body) = api.post_body("/files", content_type, reader, content_length); pb.finish_and_clear(); - if !resp.status().is_success() { + if !status.is_success() { use crossterm::style::Stylize; - eprintln!("{}", crate::util::api_error(resp.text().unwrap_or_default()).red()); + eprintln!("{}", crate::util::api_error(resp_body).red()); std::process::exit(1); } - let body: serde_json::Value = match resp.json() { + let body: serde_json::Value = match serde_json::from_str(&resp_body) { Ok(v) => v, Err(e) => { eprintln!("error parsing upload response: {e}"); @@ -171,10 +146,7 @@ fn do_upload( // Returns (upload_id, format) fn upload_from_file( - client: &reqwest::blocking::Client, - api_key: &str, - workspace_id: &str, - api_url: &str, + api: &ApiClient, path: &str, ) -> (String, &'static str) { let mut f = match std::fs::File::open(path) { @@ -197,16 +169,13 @@ fn upload_from_file( let pb = make_progress_bar(file_size); let reader = pb.wrap_read(f); - let id = do_upload(client, api_key, workspace_id, api_url, ft.content_type, reader, pb, Some(file_size)); + let id = do_upload(api, ft.content_type, reader, pb, Some(file_size)); (id, ft.format) } // Returns (upload_id, format) fn upload_from_stdin( - client: &reqwest::blocking::Client, - api_key: &str, - workspace_id: &str, - api_url: &str, + api: &ApiClient, ) -> (String, &'static str) { use std::io::Read; let mut probe = [0u8; 512]; @@ -223,65 +192,34 @@ fn upload_from_stdin( pb.enable_steady_tick(std::time::Duration::from_millis(80)); let reader = pb.wrap_read(reader); - let id = do_upload(client, api_key, workspace_id, api_url, ft.content_type, reader, pb, None); + let id = do_upload(api, ft.content_type, reader, pb, None); (id, ft.format) } fn create_dataset( - workspace_id: &str, + api: &ApiClient, label: &str, table_name: Option<&str>, source: serde_json::Value, on_failure: Option>, ) { - let profile_config = match config::load("default") { - Ok(c) => c, - Err(e) => { - eprintln!("{e}"); - std::process::exit(1); - } - }; - - let api_key = match &profile_config.api_key { - Some(key) if key != "PLACEHOLDER" => key.clone(), - _ => { - eprintln!("error: not authenticated. Run 'hotdata auth' to log in."); - std::process::exit(1); - } - }; - let mut body = json!({ "label": label, "source": source }); if let Some(tn) = table_name { body["table_name"] = json!(tn); } - let url = format!("{}/datasets", profile_config.api_url); - let client = reqwest::blocking::Client::new(); + let (status, resp_body) = api.post_raw("/datasets", &body); - let resp = match client - .post(&url) - .header("Authorization", format!("Bearer {api_key}")) - .header("X-Workspace-Id", workspace_id) - .json(&body) - .send() - { - Ok(r) => r, - Err(e) => { - eprintln!("error connecting to API: {e}"); - std::process::exit(1); - } - }; - - if !resp.status().is_success() { + if !status.is_success() { use crossterm::style::Stylize; - eprintln!("{}", crate::util::api_error(resp.text().unwrap_or_default()).red()); + eprintln!("{}", crate::util::api_error(resp_body).red()); if let Some(f) = on_failure { f(); } std::process::exit(1); } - let dataset: CreateResponse = match resp.json() { + let dataset: CreateResponse = match serde_json::from_str(&resp_body) { Ok(v) => v, Err(e) => { eprintln!("error parsing response: {e}"); @@ -304,21 +242,7 @@ pub fn create_from_upload( upload_id: Option<&str>, source_format: &str, ) { - let profile_config = match config::load("default") { - Ok(c) => c, - Err(e) => { - eprintln!("{e}"); - std::process::exit(1); - } - }; - - let api_key = match &profile_config.api_key { - Some(key) if key != "PLACEHOLDER" => key.clone(), - _ => { - eprintln!("error: not authenticated. Run 'hotdata auth' to log in."); - std::process::exit(1); - } - }; + let api = ApiClient::new(Some(workspace_id)); let label_derived; let label: &str = match label { @@ -351,20 +275,18 @@ pub fn create_from_upload( }, }; - let client = reqwest::blocking::Client::new(); - let (upload_id, format, upload_id_was_uploaded): (String, &str, bool) = if let Some(id) = upload_id { (id.to_string(), source_format, false) } else { let (id, fmt) = match file { - Some(path) => upload_from_file(&client, &api_key, workspace_id, &profile_config.api_url, path), + Some(path) => upload_from_file(&api, path), None => { use std::io::IsTerminal; if std::io::stdin().is_terminal() { eprintln!("error: no input data. Use --file , --upload-id , or pipe data via stdin."); std::process::exit(1); } - upload_from_stdin(&client, &api_key, workspace_id, &profile_config.api_url) + upload_from_stdin(&api) } }; (id, fmt, true) @@ -385,7 +307,7 @@ pub fn create_from_upload( None }; - create_dataset(workspace_id, label, table_name, source, on_failure); + create_dataset(&api, label, table_name, source, on_failure); } pub fn create_from_url( @@ -401,7 +323,8 @@ pub fn create_from_url( std::process::exit(1); } }; - create_dataset(workspace_id, label, table_name, json!({ "Url": { "url": url } }), None); + let api = ApiClient::new(Some(workspace_id)); + create_dataset(&api, label, table_name, json!({ "Url": { "url": url } }), None); } pub fn create_from_query( @@ -417,7 +340,8 @@ pub fn create_from_query( std::process::exit(1); } }; - create_dataset(workspace_id, label, table_name, json!({ "sql": sql }), None); + let api = ApiClient::new(Some(workspace_id)); + create_dataset(&api, label, table_name, json!({ "sql": sql }), None); } pub fn create_from_saved_query( @@ -433,59 +357,18 @@ pub fn create_from_saved_query( std::process::exit(1); } }; - create_dataset(workspace_id, label, table_name, json!({ "saved_query_id": query_id }), None); + let api = ApiClient::new(Some(workspace_id)); + create_dataset(&api, label, table_name, json!({ "saved_query_id": query_id }), None); } pub fn list(workspace_id: &str, limit: Option, offset: Option, format: &str) { - let profile_config = match config::load("default") { - Ok(c) => c, - Err(e) => { - eprintln!("{e}"); - std::process::exit(1); - } - }; - - let api_key = match &profile_config.api_key { - Some(key) if key != "PLACEHOLDER" => key.clone(), - _ => { - eprintln!("error: not authenticated. Run 'hotdata auth' to log in."); - std::process::exit(1); - } - }; - - let mut url = format!("{}/datasets", profile_config.api_url); - let mut params = vec![]; - if let Some(l) = limit { params.push(format!("limit={l}")); } - if let Some(o) = offset { params.push(format!("offset={o}")); } - if !params.is_empty() { url = format!("{url}?{}", params.join("&")); } - - let client = reqwest::blocking::Client::new(); - let resp = match client - .get(&url) - .header("Authorization", format!("Bearer {api_key}")) - .header("X-Workspace-Id", workspace_id) - .send() - { - Ok(r) => r, - Err(e) => { - eprintln!("error connecting to API: {e}"); - std::process::exit(1); - } - }; - - if !resp.status().is_success() { - use crossterm::style::Stylize; - eprintln!("{}", crate::util::api_error(resp.text().unwrap_or_default()).red()); - std::process::exit(1); - } + let api = ApiClient::new(Some(workspace_id)); - let body: ListResponse = match resp.json() { - Ok(v) => v, - Err(e) => { - eprintln!("error parsing response: {e}"); - std::process::exit(1); - } - }; + let params = [ + ("limit", limit.map(|l| l.to_string())), + ("offset", offset.map(|o| o.to_string())), + ]; + let body: ListResponse = api.get_with_params("/datasets", ¶ms); match format { "json" => println!("{}", serde_json::to_string_pretty(&body.datasets).unwrap()), @@ -514,51 +397,9 @@ pub fn list(workspace_id: &str, limit: Option, offset: Option, format: } pub fn get(dataset_id: &str, workspace_id: &str, format: &str) { - let profile_config = match config::load("default") { - Ok(c) => c, - Err(e) => { - eprintln!("{e}"); - std::process::exit(1); - } - }; + let api = ApiClient::new(Some(workspace_id)); - let api_key = match &profile_config.api_key { - Some(key) if key != "PLACEHOLDER" => key.clone(), - _ => { - eprintln!("error: not authenticated. Run 'hotdata auth' to log in."); - std::process::exit(1); - } - }; - - let url = format!("{}/datasets/{dataset_id}", profile_config.api_url); - let client = reqwest::blocking::Client::new(); - - let resp = match client - .get(&url) - .header("Authorization", format!("Bearer {api_key}")) - .header("X-Workspace-Id", workspace_id) - .send() - { - Ok(r) => r, - Err(e) => { - eprintln!("error connecting to API: {e}"); - std::process::exit(1); - } - }; - - if !resp.status().is_success() { - use crossterm::style::Stylize; - eprintln!("{}", crate::util::api_error(resp.text().unwrap_or_default()).red()); - std::process::exit(1); - } - - let d: DatasetDetail = match resp.json() { - Ok(v) => v, - Err(e) => { - eprintln!("error parsing response: {e}"); - std::process::exit(1); - } - }; + let d: DatasetDetail = api.get(&format!("/datasets/{dataset_id}")); match format { "json" => println!("{}", serde_json::to_string_pretty(&d).unwrap()), diff --git a/src/indexes.rs b/src/indexes.rs index c3ad5a9..c67eff6 100644 --- a/src/indexes.rs +++ b/src/indexes.rs @@ -1,4 +1,4 @@ -use crate::config; +use crate::api::ApiClient; use serde::{Deserialize, Serialize}; #[derive(Deserialize, Serialize)] @@ -24,54 +24,9 @@ pub fn list( table: &str, format: &str, ) { - let profile_config = match config::load("default") { - Ok(c) => c, - Err(e) => { - eprintln!("{e}"); - std::process::exit(1); - } - }; - - let api_key = match &profile_config.api_key { - Some(key) if key != "PLACEHOLDER" => key.clone(), - _ => { - eprintln!("error: not authenticated. Run 'hotdata auth' to log in."); - std::process::exit(1); - } - }; - - let url = format!( - "{}/connections/{}/tables/{}/{}/indexes", - profile_config.api_url, connection_id, schema, table - ); - let client = reqwest::blocking::Client::new(); - - let resp = match client - .get(&url) - .header("Authorization", format!("Bearer {api_key}")) - .header("X-Workspace-Id", workspace_id) - .send() - { - Ok(r) => r, - Err(e) => { - eprintln!("error connecting to API: {e}"); - std::process::exit(1); - } - }; - - if !resp.status().is_success() { - use crossterm::style::Stylize; - eprintln!("{}", crate::util::api_error(resp.text().unwrap_or_default()).red()); - std::process::exit(1); - } - - let body: ListResponse = match resp.json() { - Ok(v) => v, - Err(e) => { - eprintln!("error parsing response: {e}"); - std::process::exit(1); - } - }; + let api = ApiClient::new(Some(workspace_id)); + let path = format!("/connections/{connection_id}/tables/{schema}/{table}/indexes"); + let body: ListResponse = api.get(&path); match format { "json" => println!("{}", serde_json::to_string_pretty(&body.indexes).unwrap()), @@ -107,21 +62,7 @@ pub fn create( metric: Option<&str>, async_mode: bool, ) { - let profile_config = match config::load("default") { - Ok(c) => c, - Err(e) => { - eprintln!("{e}"); - std::process::exit(1); - } - }; - - let api_key = match &profile_config.api_key { - Some(key) if key != "PLACEHOLDER" => key.clone(), - _ => { - eprintln!("error: not authenticated. Run 'hotdata auth' to log in."); - std::process::exit(1); - } - }; + let api = ApiClient::new(Some(workspace_id)); let cols: Vec<&str> = columns.split(',').map(str::trim).collect(); let mut body = serde_json::json!({ @@ -134,36 +75,19 @@ pub fn create( body["metric"] = serde_json::json!(m); } - let url = format!( - "{}/connections/{}/tables/{}/{}/indexes", - profile_config.api_url, connection_id, schema, table - ); - let client = reqwest::blocking::Client::new(); - - let resp = match client - .post(&url) - .header("Authorization", format!("Bearer {api_key}")) - .header("X-Workspace-Id", workspace_id) - .json(&body) - .send() - { - Ok(r) => r, - Err(e) => { - eprintln!("error connecting to API: {e}"); - std::process::exit(1); - } - }; + let path = format!("/connections/{connection_id}/tables/{schema}/{table}/indexes"); + let (status, resp_body) = api.post_raw(&path, &body); - if !resp.status().is_success() { + if !status.is_success() { use crossterm::style::Stylize; - eprintln!("{}", crate::util::api_error(resp.text().unwrap_or_default()).red()); + eprintln!("{}", crate::util::api_error(resp_body).red()); std::process::exit(1); } use crossterm::style::Stylize; if async_mode { - let body: serde_json::Value = resp.json().unwrap_or_default(); - let job_id = body["job_id"].as_str().unwrap_or("unknown"); + let parsed: serde_json::Value = serde_json::from_str(&resp_body).unwrap_or_default(); + let job_id = parsed["job_id"].as_str().unwrap_or("unknown"); println!("{}", "Index creation submitted.".green()); println!("job_id: {}", job_id); println!("{}", "Use 'hotdata jobs ' to check status.".dark_grey()); diff --git a/src/jobs.rs b/src/jobs.rs index 87c57ed..8ef4105 100644 --- a/src/jobs.rs +++ b/src/jobs.rs @@ -1,4 +1,4 @@ -use crate::config; +use crate::api::ApiClient; use serde::{Deserialize, Serialize}; #[derive(Deserialize, Serialize)] @@ -19,51 +19,8 @@ struct ListResponse { } pub fn get(job_id: &str, workspace_id: &str, format: &str) { - let profile_config = match config::load("default") { - Ok(c) => c, - Err(e) => { - eprintln!("{e}"); - std::process::exit(1); - } - }; - - let api_key = match &profile_config.api_key { - Some(key) if key != "PLACEHOLDER" => key.clone(), - _ => { - eprintln!("error: not authenticated. Run 'hotdata auth' to log in."); - std::process::exit(1); - } - }; - - let url = format!("{}/jobs/{job_id}", profile_config.api_url); - let client = reqwest::blocking::Client::new(); - - let resp = match client - .get(&url) - .header("Authorization", format!("Bearer {api_key}")) - .header("X-Workspace-Id", workspace_id) - .send() - { - Ok(r) => r, - Err(e) => { - eprintln!("error connecting to API: {e}"); - std::process::exit(1); - } - }; - - if !resp.status().is_success() { - use crossterm::style::Stylize; - eprintln!("{}", crate::util::api_error(resp.text().unwrap_or_default()).red()); - std::process::exit(1); - } - - let job: Job = match resp.json() { - Ok(v) => v, - Err(e) => { - eprintln!("error parsing response: {e}"); - std::process::exit(1); - } - }; + let api = ApiClient::new(Some(workspace_id)); + let job: Job = api.get(&format!("/jobs/{job_id}")); match format { "json" => println!("{}", serde_json::to_string_pretty(&job).unwrap()), @@ -98,50 +55,20 @@ pub fn get(job_id: &str, workspace_id: &str, format: &str) { } fn fetch_jobs( - client: &reqwest::blocking::Client, - api_key: &str, - api_url: &str, - workspace_id: &str, + api: &ApiClient, job_type: Option<&str>, status: Option<&str>, limit: Option, offset: Option, ) -> Vec { - let mut params = vec![]; - if let Some(jt) = job_type { params.push(format!("job_type={jt}")); } - if let Some(s) = status { params.push(format!("status={s}")); } - if let Some(l) = limit { params.push(format!("limit={l}")); } - if let Some(o) = offset { params.push(format!("offset={o}")); } - - let mut url = format!("{api_url}/jobs"); - if !params.is_empty() { url = format!("{url}?{}", params.join("&")); } - - let resp = match client - .get(&url) - .header("Authorization", format!("Bearer {api_key}")) - .header("X-Workspace-Id", workspace_id) - .send() - { - Ok(r) => r, - Err(e) => { - eprintln!("error connecting to API: {e}"); - std::process::exit(1); - } - }; - - if !resp.status().is_success() { - use crossterm::style::Stylize; - eprintln!("{}", crate::util::api_error(resp.text().unwrap_or_default()).red()); - std::process::exit(1); - } - - match resp.json::() { - Ok(v) => v.jobs, - Err(e) => { - eprintln!("error parsing response: {e}"); - std::process::exit(1); - } - } + let params = [ + ("job_type", job_type.map(String::from)), + ("status", status.map(String::from)), + ("limit", limit.map(|l| l.to_string())), + ("offset", offset.map(|o| o.to_string())), + ]; + let resp: ListResponse = api.get_with_params("/jobs", ¶ms); + resp.jobs } pub fn list( @@ -153,30 +80,13 @@ pub fn list( offset: Option, format: &str, ) { - let profile_config = match config::load("default") { - Ok(c) => c, - Err(e) => { - eprintln!("{e}"); - std::process::exit(1); - } - }; - - let api_key = match &profile_config.api_key { - Some(key) if key != "PLACEHOLDER" => key.clone(), - _ => { - eprintln!("error: not authenticated. Run 'hotdata auth' to log in."); - std::process::exit(1); - } - }; - - let client = reqwest::blocking::Client::new(); - let api_url = profile_config.api_url.to_string(); + let api = ApiClient::new(Some(workspace_id)); let jobs = if !all && status.is_none() { // Default: show only active jobs (pending + running) - fetch_jobs(&client, &api_key, &api_url, workspace_id, job_type, Some("pending,running"), limit, offset) + fetch_jobs(&api, job_type, Some("pending,running"), limit, offset) } else { - fetch_jobs(&client, &api_key, &api_url, workspace_id, job_type, status, limit, offset) + fetch_jobs(&api, job_type, status, limit, offset) }; let body = ListResponse { jobs }; diff --git a/src/main.rs b/src/main.rs index 9d4eb65..60a4e31 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,4 @@ +mod api; mod auth; mod command; mod config; @@ -31,6 +32,10 @@ struct Cli { #[arg(long, global = true)] api_key: Option, + /// Print verbose API request and response details + #[arg(long, global = true)] + debug: bool, + #[command(subcommand)] command: Option, } @@ -58,6 +63,9 @@ fn main() { if let Some(key) = cli.api_key { config::set_api_key_flag(key); } + if cli.debug { + util::set_debug(true); + } match cli.command { None => { @@ -71,14 +79,14 @@ fn main() { Some(AuthCommands::Status) => auth::status("default"), Some(AuthCommands::Logout) => auth::logout("default"), }, - Commands::Datasets { id, workspace_id, format, command } => { + Commands::Datasets { id, workspace_id, output, command } => { let workspace_id = resolve_workspace(workspace_id); if let Some(id) = id { - datasets::get(&id, &workspace_id, &format) + datasets::get(&id, &workspace_id, &output) } else { match command { - Some(DatasetsCommands::List { limit, offset, format }) => { - datasets::list(&workspace_id, limit, offset, &format) + Some(DatasetsCommands::List { limit, offset, output }) => { + datasets::list(&workspace_id, limit, offset, &output) } Some(DatasetsCommands::Create { label, table_name, file, upload_id, format, sql, query_id, url }) => { if let Some(sql) = sql { @@ -100,12 +108,12 @@ fn main() { } } } - Commands::Query { sql, workspace_id, connection, format } => { + Commands::Query { sql, workspace_id, connection, output } => { let workspace_id = resolve_workspace(workspace_id); - query::execute(&sql, &workspace_id, connection.as_deref(), &format) + query::execute(&sql, &workspace_id, connection.as_deref(), &output) } Commands::Workspaces { command } => match command { - WorkspaceCommands::List { format } => workspace::list(&format), + WorkspaceCommands::List { output } => workspace::list(&output), WorkspaceCommands::Set { workspace_id } => workspace::set(workspace_id.as_deref()), _ => eprintln!("not yet implemented"), }, @@ -113,15 +121,15 @@ fn main() { let workspace_id = resolve_workspace(workspace_id); match command { ConnectionsCommands::New => connections_new::run(&workspace_id), - ConnectionsCommands::List { format } => { - connections::list(&workspace_id, &format) + ConnectionsCommands::List { output } => { + connections::list(&workspace_id, &output) } - ConnectionsCommands::Create { command, name, source_type, config, format } => { + ConnectionsCommands::Create { command, name, source_type, config, output } => { match command { - Some(ConnectionsCreateCommands::List { name, format }) => { + Some(ConnectionsCreateCommands::List { name, output }) => { match name.as_deref() { - Some(name) => connections::types_get(&workspace_id, name, &format), - None => connections::types_list(&workspace_id, &format), + Some(name) => connections::types_get(&workspace_id, name, &output), + None => connections::types_list(&workspace_id, &output), } } None => { @@ -139,7 +147,7 @@ fn main() { &name.unwrap(), &source_type.unwrap(), &config.unwrap(), - &format, + &output, ) } } @@ -151,9 +159,9 @@ fn main() { } }, Commands::Tables { command } => match command { - TablesCommands::List { workspace_id, connection_id, schema, table, limit, cursor, format } => { + TablesCommands::List { workspace_id, connection_id, schema, table, limit, cursor, output } => { let workspace_id = resolve_workspace(workspace_id); - tables::list(&workspace_id, connection_id.as_deref(), schema.as_deref(), table.as_deref(), limit, cursor.as_deref(), &format) + tables::list(&workspace_id, connection_id.as_deref(), schema.as_deref(), table.as_deref(), limit, cursor.as_deref(), &output) } }, Commands::Skills { command } => match command { @@ -162,15 +170,15 @@ fn main() { } SkillCommands::Status => skill::status(), }, - Commands::Results { result_id, workspace_id, format, command } => { + Commands::Results { result_id, workspace_id, output, command } => { let workspace_id = resolve_workspace(workspace_id); match command { - Some(ResultsCommands::List { limit, offset, format }) => { - results::list(&workspace_id, limit, offset, &format) + Some(ResultsCommands::List { limit, offset, output }) => { + results::list(&workspace_id, limit, offset, &output) } None => { match result_id { - Some(id) => results::get(&id, &workspace_id, &format), + Some(id) => results::get(&id, &workspace_id, &output), None => { use clap::CommandFactory; let mut cmd = Cli::command(); @@ -181,14 +189,14 @@ fn main() { } } } - Commands::Jobs { id, workspace_id, format, command } => { + Commands::Jobs { id, workspace_id, output, command } => { let workspace_id = resolve_workspace(workspace_id); if let Some(id) = id { - jobs::get(&id, &workspace_id, &format) + jobs::get(&id, &workspace_id, &output) } else { match command { - Some(JobsCommands::List { job_type, status, all, limit, offset, format }) => { - jobs::list(&workspace_id, job_type.as_deref(), status.as_deref(), all, limit, offset, &format) + Some(JobsCommands::List { job_type, status, all, limit, offset, output }) => { + jobs::list(&workspace_id, job_type.as_deref(), status.as_deref(), all, limit, offset, &output) } None => { use clap::CommandFactory; @@ -202,15 +210,15 @@ fn main() { Commands::Indexes { workspace_id, command } => { let workspace_id = resolve_workspace(workspace_id); match command { - IndexesCommands::List { connection_id, schema, table, format } => { - indexes::list(&workspace_id, &connection_id, &schema, &table, &format) + IndexesCommands::List { connection_id, schema, table, output } => { + indexes::list(&workspace_id, &connection_id, &schema, &table, &output) } IndexesCommands::Create { connection_id, schema, table, name, columns, r#type, metric, r#async } => { indexes::create(&workspace_id, &connection_id, &schema, &table, &name, &columns, &r#type, metric.as_deref(), r#async) } } } - Commands::Search { query, table, column, select, limit, workspace_id, format } => { + Commands::Search { query, table, column, select, limit, workspace_id, output } => { let workspace_id = resolve_workspace(workspace_id); let columns = match select.as_deref() { Some(cols) => format!("{}, score", cols), @@ -224,25 +232,25 @@ fn main() { query.replace('\'', "''"), limit, ); - query::execute(&sql, &workspace_id, None, &format) + query::execute(&sql, &workspace_id, None, &output) } - Commands::Queries { id, format, command } => { + Commands::Queries { id, output, command } => { let workspace_id = resolve_workspace(None); if let Some(id) = id { - queries::get(&id, &workspace_id, &format) + queries::get(&id, &workspace_id, &output) } else { match command { - Some(QueriesCommands::List { limit, offset, format }) => { - queries::list(&workspace_id, limit, offset, &format) + Some(QueriesCommands::List { limit, offset, output }) => { + queries::list(&workspace_id, limit, offset, &output) } - Some(QueriesCommands::Run { id, format }) => { - queries::run(&id, &workspace_id, &format) + Some(QueriesCommands::Run { id, output }) => { + queries::run(&id, &workspace_id, &output) } - Some(QueriesCommands::Create { name, sql, description, tags, format }) => { - queries::create(&workspace_id, &name, &sql, description.as_deref(), tags.as_deref(), &format) + Some(QueriesCommands::Create { name, sql, description, tags, output }) => { + queries::create(&workspace_id, &name, &sql, description.as_deref(), tags.as_deref(), &output) } - Some(QueriesCommands::Update { id, name, sql, description, tags, category, table_size, format }) => { - queries::update(&workspace_id, &id, name.as_deref(), sql.as_deref(), description.as_deref(), tags.as_deref(), category.as_deref(), table_size.as_deref(), &format) + Some(QueriesCommands::Update { id, name, sql, description, tags, category, table_size, output }) => { + queries::update(&workspace_id, &id, name.as_deref(), sql.as_deref(), description.as_deref(), tags.as_deref(), category.as_deref(), table_size.as_deref(), &output) } None => { use clap::CommandFactory; diff --git a/src/queries.rs b/src/queries.rs index 2e7a81d..104fdb5 100644 --- a/src/queries.rs +++ b/src/queries.rs @@ -1,4 +1,4 @@ -use crate::config; +use crate::api::ApiClient; use crossterm::style::Stylize; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -158,62 +158,18 @@ struct ListResponse { } pub fn list(workspace_id: &str, limit: Option, offset: Option, format: &str) { - let profile_config = match config::load("default") { - Ok(c) => c, - Err(e) => { - eprintln!("{e}"); - std::process::exit(1); - } - }; - - let api_key = match &profile_config.api_key { - Some(key) if key != "PLACEHOLDER" => key.clone(), - _ => { - eprintln!("error: not authenticated. Run 'hotdata auth' to log in."); - std::process::exit(1); - } - }; - - let mut url = format!("{}/queries", profile_config.api_url); - let mut params = vec![]; - if let Some(l) = limit { params.push(format!("limit={l}")); } - if let Some(o) = offset { params.push(format!("offset={o}")); } - if !params.is_empty() { url = format!("{url}?{}", params.join("&")); } - - let client = reqwest::blocking::Client::new(); - let resp = match client - .get(&url) - .header("Authorization", format!("Bearer {api_key}")) - .header("X-Workspace-Id", workspace_id) - .send() - { - Ok(r) => r, - Err(e) => { - eprintln!("error connecting to API: {e}"); - std::process::exit(1); - } - }; - - if !resp.status().is_success() { - use crossterm::style::Stylize; - eprintln!("{}", crate::util::api_error(resp.text().unwrap_or_default()).red()); - std::process::exit(1); - } - - let body: ListResponse = match resp.json() { - Ok(v) => v, - Err(e) => { - eprintln!("error parsing response: {e}"); - std::process::exit(1); - } - }; + let api = ApiClient::new(Some(workspace_id)); + let params = [ + ("limit", limit.map(|l| l.to_string())), + ("offset", offset.map(|o| o.to_string())), + ]; + let body: ListResponse = api.get_with_params("/queries", ¶ms); match format { "json" => println!("{}", serde_json::to_string_pretty(&body.queries).unwrap()), "yaml" => print!("{}", serde_yaml::to_string(&body.queries).unwrap()), "table" => { if body.queries.is_empty() { - use crossterm::style::Stylize; eprintln!("{}", "No saved queries found.".dark_grey()); } else { let rows: Vec> = body.queries.iter().map(|q| vec![ @@ -228,7 +184,6 @@ pub fn list(workspace_id: &str, limit: Option, offset: Option, format: } if body.has_more { let next = offset.unwrap_or(0) + body.count as u32; - use crossterm::style::Stylize; eprintln!("{}", format!("showing {} results — use --offset {next} for more", body.count).dark_grey()); } } @@ -237,52 +192,9 @@ pub fn list(workspace_id: &str, limit: Option, offset: Option, format: } pub fn get(query_id: &str, workspace_id: &str, format: &str) { - let profile_config = match config::load("default") { - Ok(c) => c, - Err(e) => { - eprintln!("{e}"); - std::process::exit(1); - } - }; - - let api_key = match &profile_config.api_key { - Some(key) if key != "PLACEHOLDER" => key.clone(), - _ => { - eprintln!("error: not authenticated. Run 'hotdata auth' to log in."); - std::process::exit(1); - } - }; - - let url = format!("{}/queries/{query_id}", profile_config.api_url); - let client = reqwest::blocking::Client::new(); - - let resp = match client - .get(&url) - .header("Authorization", format!("Bearer {api_key}")) - .header("X-Workspace-Id", workspace_id) - .send() - { - Ok(r) => r, - Err(e) => { - eprintln!("error connecting to API: {e}"); - std::process::exit(1); - } - }; - - if !resp.status().is_success() { - use crossterm::style::Stylize; - eprintln!("{}", crate::util::api_error(resp.text().unwrap_or_default()).red()); - std::process::exit(1); - } - - let q: SavedQueryDetail = match resp.json() { - Ok(v) => v, - Err(e) => { - eprintln!("error parsing response: {e}"); - std::process::exit(1); - } - }; - + let api = ApiClient::new(Some(workspace_id)); + let path = format!("/queries/{query_id}"); + let q: SavedQueryDetail = api.get(&path); print_detail(&q, format); } @@ -331,55 +243,13 @@ pub fn create( tags: Option<&str>, format: &str, ) { - let profile_config = match config::load("default") { - Ok(c) => c, - Err(e) => { - eprintln!("{e}"); - std::process::exit(1); - } - }; - - let api_key = match &profile_config.api_key { - Some(key) if key != "PLACEHOLDER" => key.clone(), - _ => { - eprintln!("error: not authenticated. Run 'hotdata auth' to log in."); - std::process::exit(1); - } - }; + let api = ApiClient::new(Some(workspace_id)); let mut body = serde_json::json!({ "name": name, "sql": sql }); if let Some(d) = description { body["description"] = serde_json::json!(d); } if let Some(tags) = parse_tags(tags) { body["tags"] = serde_json::json!(tags); } - let url = format!("{}/queries", profile_config.api_url); - let client = reqwest::blocking::Client::new(); - - let resp = match client - .post(&url) - .header("Authorization", format!("Bearer {api_key}")) - .header("X-Workspace-Id", workspace_id) - .json(&body) - .send() - { - Ok(r) => r, - Err(e) => { - eprintln!("error connecting to API: {e}"); - std::process::exit(1); - } - }; - - if !resp.status().is_success() { - eprintln!("{}", crate::util::api_error(resp.text().unwrap_or_default()).red()); - std::process::exit(1); - } - - let q: SavedQueryDetail = match resp.json() { - Ok(v) => v, - Err(e) => { - eprintln!("error parsing response: {e}"); - std::process::exit(1); - } - }; + let q: SavedQueryDetail = api.post("/queries", &body); println!("{}", "Query created".green()); print_detail(&q, format); @@ -396,27 +266,13 @@ pub fn update( table_size: Option<&str>, format: &str, ) { - let profile_config = match config::load("default") { - Ok(c) => c, - Err(e) => { - eprintln!("{e}"); - std::process::exit(1); - } - }; - - let api_key = match &profile_config.api_key { - Some(key) if key != "PLACEHOLDER" => key.clone(), - _ => { - eprintln!("error: not authenticated. Run 'hotdata auth' to log in."); - std::process::exit(1); - } - }; - if name.is_none() && sql.is_none() && description.is_none() && tags.is_none() && category.is_none() && table_size.is_none() { eprintln!("error: no fields to update. Provide at least one of --name, --sql, --description, --tags, --category, or --table-size."); std::process::exit(1); } + let api = ApiClient::new(Some(workspace_id)); + let mut body = serde_json::json!({}); if let Some(n) = name { body["name"] = serde_json::json!(n); } if let Some(s) = sql { body["sql"] = serde_json::json!(s); } @@ -433,85 +289,16 @@ pub fn update( None => {} } - let url = format!("{}/queries/{id}", profile_config.api_url); - let client = reqwest::blocking::Client::new(); - - let resp = match client - .put(&url) - .header("Authorization", format!("Bearer {api_key}")) - .header("X-Workspace-Id", workspace_id) - .json(&body) - .send() - { - Ok(r) => r, - Err(e) => { - eprintln!("error connecting to API: {e}"); - std::process::exit(1); - } - }; - - if !resp.status().is_success() { - eprintln!("{}", crate::util::api_error(resp.text().unwrap_or_default()).red()); - std::process::exit(1); - } - - let q: SavedQueryDetail = match resp.json() { - Ok(v) => v, - Err(e) => { - eprintln!("error parsing response: {e}"); - std::process::exit(1); - } - }; + let path = format!("/queries/{id}"); + let q: SavedQueryDetail = api.put(&path, &body); println!("{}", "Query updated".green()); print_detail(&q, format); } pub fn run(query_id: &str, workspace_id: &str, format: &str) { - let profile_config = match config::load("default") { - Ok(c) => c, - Err(e) => { - eprintln!("{e}"); - std::process::exit(1); - } - }; - - let api_key = match &profile_config.api_key { - Some(key) if key != "PLACEHOLDER" => key.clone(), - _ => { - eprintln!("error: not authenticated. Run 'hotdata auth' to log in."); - std::process::exit(1); - } - }; - - let url = format!("{}/queries/{query_id}/execute", profile_config.api_url); - let client = reqwest::blocking::Client::new(); - - let resp = match client - .post(&url) - .header("Authorization", format!("Bearer {api_key}")) - .header("X-Workspace-Id", workspace_id) - .send() - { - Ok(r) => r, - Err(e) => { - eprintln!("error connecting to API: {e}"); - std::process::exit(1); - } - }; - - if !resp.status().is_success() { - eprintln!("{}", crate::util::api_error(resp.text().unwrap_or_default()).red()); - std::process::exit(1); - } - - let result: crate::query::QueryResponse = match resp.json() { - Ok(v) => v, - Err(e) => { - eprintln!("error parsing response: {e}"); - std::process::exit(1); - } - }; - + let api = ApiClient::new(Some(workspace_id)); + let path = format!("/queries/{query_id}/execute"); + let result: crate::query::QueryResponse = api.post_empty(&path); crate::query::print_result(&result, format); } diff --git a/src/query.rs b/src/query.rs index da9298e..b1a8e55 100644 --- a/src/query.rs +++ b/src/query.rs @@ -1,4 +1,4 @@ -use crate::config; +use crate::api::ApiClient; use serde::Deserialize; use serde_json::Value; @@ -8,6 +8,7 @@ pub struct QueryResponse { pub columns: Vec, pub rows: Vec>, pub row_count: u64, + #[serde(default)] pub execution_time_ms: u64, pub warning: Option, } @@ -23,57 +24,26 @@ fn value_to_string(v: &Value) -> String { } pub fn execute(sql: &str, workspace_id: &str, connection: Option<&str>, format: &str) { - let profile_config = match config::load("default") { - Ok(c) => c, - Err(e) => { - eprintln!("{e}"); - std::process::exit(1); - } - }; - - let api_key = match &profile_config.api_key { - Some(key) if key != "PLACEHOLDER" => key.clone(), - _ => { - eprintln!("error: not authenticated. Run 'hotdata auth' to log in."); - std::process::exit(1); - } - }; - - let url = format!("{}/query", profile_config.api_url); - let client = reqwest::blocking::Client::new(); + let api = ApiClient::new(Some(workspace_id)); let mut body = serde_json::json!({ "sql": sql }); if let Some(conn) = connection { body["connection_id"] = Value::String(conn.to_string()); } - let resp = match client - .post(&url) - .header("Authorization", format!("Bearer {api_key}")) - .header("X-Workspace-Id", workspace_id) - .json(&body) - .send() - { - Ok(r) => r, - Err(e) => { - eprintln!("error connecting to API: {e}"); - std::process::exit(1); - } - }; + let (status, resp_body) = api.post_raw("/query", &body); - if !resp.status().is_success() { - let _status = resp.status(); - let body = resp.text().unwrap_or_default(); - let message = serde_json::from_str::(&body) + if !status.is_success() { + let message = serde_json::from_str::(&resp_body) .ok() .and_then(|v| v["error"]["message"].as_str().map(str::to_string)) - .unwrap_or(body); + .unwrap_or(resp_body); use crossterm::style::Stylize; eprintln!("{}", message.red()); std::process::exit(1); } - let result: QueryResponse = match resp.json() { + let result: QueryResponse = match serde_json::from_str(&resp_body) { Ok(r) => r, Err(e) => { eprintln!("error parsing response: {e}"); diff --git a/src/results.rs b/src/results.rs index cbf9c68..d36deae 100644 --- a/src/results.rs +++ b/src/results.rs @@ -1,30 +1,5 @@ -use crate::config; +use crate::api::ApiClient; use serde::{Deserialize, Serialize}; -use serde_json::Value; - -#[derive(Deserialize)] -#[allow(dead_code)] -struct ResultResponse { - result_id: String, - status: Option, - columns: Vec, - #[serde(default)] - nullable: Vec, - rows: Vec>, - row_count: u64, - #[serde(default)] - execution_time_ms: u64, -} - -fn value_to_string(v: &Value) -> String { - match v { - Value::Null => "NULL".to_string(), - Value::Bool(b) => b.to_string(), - Value::Number(n) => n.to_string(), - Value::String(s) => s.clone(), - Value::Array(_) | Value::Object(_) => v.to_string(), - } -} #[derive(Deserialize, Serialize)] struct ResultEntry { @@ -41,55 +16,13 @@ struct ListResponse { } pub fn list(workspace_id: &str, limit: Option, offset: Option, format: &str) { - let profile_config = match config::load("default") { - Ok(c) => c, - Err(e) => { - eprintln!("{e}"); - std::process::exit(1); - } - }; - - let api_key = match &profile_config.api_key { - Some(key) if key != "PLACEHOLDER" => key.clone(), - _ => { - eprintln!("error: not authenticated. Run 'hotdata auth' to log in."); - std::process::exit(1); - } - }; - - let mut url = format!("{}/results", profile_config.api_url); - let mut params = vec![]; - if let Some(l) = limit { params.push(format!("limit={l}")); } - if let Some(o) = offset { params.push(format!("offset={o}")); } - if !params.is_empty() { url = format!("{url}?{}", params.join("&")); } - - let client = reqwest::blocking::Client::new(); - let resp = match client - .get(&url) - .header("Authorization", format!("Bearer {api_key}")) - .header("X-Workspace-Id", workspace_id) - .send() - { - Ok(r) => r, - Err(e) => { - eprintln!("error connecting to API: {e}"); - std::process::exit(1); - } - }; + let api = ApiClient::new(Some(workspace_id)); - if !resp.status().is_success() { - use crossterm::style::Stylize; - eprintln!("{}", crate::util::api_error(resp.text().unwrap_or_default()).red()); - std::process::exit(1); - } - - let body: ListResponse = match resp.json() { - Ok(v) => v, - Err(e) => { - eprintln!("error parsing response: {e}"); - std::process::exit(1); - } - }; + let params = [ + ("limit", limit.map(|l| l.to_string())), + ("offset", offset.map(|o| o.to_string())), + ]; + let body: ListResponse = api.get_with_params("/results", ¶ms); match format { "json" => println!("{}", serde_json::to_string_pretty(&body.results).unwrap()), @@ -117,87 +50,10 @@ pub fn list(workspace_id: &str, limit: Option, offset: Option, format: } pub fn get(result_id: &str, workspace_id: &str, format: &str) { - let profile_config = match config::load("default") { - Ok(c) => c, - Err(e) => { - eprintln!("{e}"); - std::process::exit(1); - } - }; + let api = ApiClient::new(Some(workspace_id)); - let api_key = match &profile_config.api_key { - Some(key) if key != "PLACEHOLDER" => key.clone(), - _ => { - eprintln!("error: not authenticated. Run 'hotdata auth' to log in."); - std::process::exit(1); - } - }; - - let url = format!("{}/results/{result_id}", profile_config.api_url); - let client = reqwest::blocking::Client::new(); - - let resp = match client - .get(&url) - .header("Authorization", format!("Bearer {api_key}")) - .header("X-Workspace-Id", workspace_id) - .send() - { - Ok(r) => r, - Err(e) => { - eprintln!("error connecting to API: {e}"); - std::process::exit(1); - } - }; - - if !resp.status().is_success() { - let body = resp.text().unwrap_or_default(); - let message = serde_json::from_str::(&body) - .ok() - .and_then(|v| v["error"]["message"].as_str().map(str::to_string)) - .unwrap_or(body); - use crossterm::style::Stylize; - eprintln!("{}", message.red()); - std::process::exit(1); - } - - let result: ResultResponse = match resp.json() { - Ok(r) => r, - Err(e) => { - eprintln!("error parsing response: {e}"); - std::process::exit(1); - } - }; + let path = format!("/results/{result_id}"); + let result: crate::query::QueryResponse = api.get(&path); - match format { - "json" => { - let out = serde_json::json!({ - "result_id": result.result_id, - "columns": result.columns, - "rows": result.rows, - "row_count": result.row_count, - "execution_time_ms": result.execution_time_ms, - }); - println!("{}", serde_json::to_string_pretty(&out).unwrap()); - } - "csv" => { - println!("{}", result.columns.join(",")); - for row in &result.rows { - let cells: Vec = row.iter().map(|v| { - let s = value_to_string(v); - if s.contains(',') || s.contains('"') || s.contains('\n') { - format!("\"{}\"", s.replace('"', "\"\"")) - } else { - s - } - }).collect(); - println!("{}", cells.join(",")); - } - } - "table" => { - crate::table::print_json(&result.columns, &result.rows); - use crossterm::style::Stylize; - eprintln!("{}", format!("\n{} row{} ({} ms) [result-id: {}]", result.row_count, if result.row_count == 1 { "" } else { "s" }, result.execution_time_ms, result.result_id).dark_grey()); - } - _ => unreachable!(), - } + crate::query::print_result(&result, format); } diff --git a/src/tables.rs b/src/tables.rs index f22088b..5ff302a 100644 --- a/src/tables.rs +++ b/src/tables.rs @@ -1,4 +1,4 @@ -use crate::config; +use crate::api::ApiClient; use serde::{Deserialize, Serialize}; #[derive(Deserialize, Serialize)] @@ -55,72 +55,27 @@ pub fn list( cursor: Option<&str>, format: &str, ) { - let profile_config = match config::load("default") { - Ok(c) => c, - Err(e) => { - eprintln!("{e}"); - std::process::exit(1); - } - }; - - let api_key = match &profile_config.api_key { - Some(key) if key != "PLACEHOLDER" => key.clone(), - _ => { - eprintln!("error: not authenticated. Run 'hotdata auth' to log in."); - std::process::exit(1); - } - }; + let api = ApiClient::new(Some(workspace_id)); - let mut params: Vec = Vec::new(); + let mut params: Vec<(&str, Option)> = Vec::new(); if let Some(id) = connection_id { - params.push(format!("connection_id={id}")); - params.push("include_columns=true".to_string()); + params.push(("connection_id", Some(id.to_string()))); + params.push(("include_columns", Some("true".to_string()))); } if let Some(s) = schema { - params.push(format!("schema={s}")); + params.push(("schema", Some(s.to_string()))); } if let Some(t) = table_filter { - params.push(format!("table={t}")); + params.push(("table", Some(t.to_string()))); } if let Some(l) = limit { - params.push(format!("limit={l}")); + params.push(("limit", Some(l.to_string()))); } if let Some(c) = cursor { - params.push(format!("cursor={c}")); + params.push(("cursor", Some(c.to_string()))); } - let mut url = format!("{}/information_schema", profile_config.api_url); - if !params.is_empty() { - url.push_str(&format!("?{}", params.join("&"))); - } - - let client = reqwest::blocking::Client::new(); - - let resp = match client - .get(&url) - .header("Authorization", format!("Bearer {api_key}")) - .header("X-Workspace-Id", workspace_id) - .send() - { - Ok(r) => r, - Err(e) => { - eprintln!("error connecting to API: {e}"); - std::process::exit(1); - } - }; - - if !resp.status().is_success() { - eprintln!("error: HTTP {}", resp.status()); - std::process::exit(1); - } - - let body: ListResponse = match resp.json() { - Ok(b) => b, - Err(e) => { - eprintln!("error parsing response: {e}"); - std::process::exit(1); - } - }; + let body: ListResponse = api.get_with_params("/information_schema", ¶ms); let has_more = body.has_more; let next_cursor = body.next_cursor.clone(); diff --git a/src/util.rs b/src/util.rs index af39430..00816c6 100644 --- a/src/util.rs +++ b/src/util.rs @@ -1,3 +1,136 @@ +use std::sync::atomic::{AtomicBool, Ordering}; + +static DEBUG: AtomicBool = AtomicBool::new(false); + +pub fn set_debug(enabled: bool) { + DEBUG.store(enabled, Ordering::Relaxed); +} + +pub fn is_debug() -> bool { + DEBUG.load(Ordering::Relaxed) +} + +/// Log request details when debug mode is enabled. +pub fn debug_request(method: &str, url: &str, headers: &[(&str, &str)], body: Option<&serde_json::Value>) { + if !is_debug() { return; } + use crossterm::style::Stylize; + eprintln!("{}", format!(">>> {method} {url}").dark_cyan()); + for (k, v) in headers { + eprintln!("{}", format!(" {k}: {v}").dark_grey()); + } + if let Some(b) = body { + eprintln!("{}", colorize_json(&serde_json::to_string_pretty(b).unwrap())); + } +} + +/// Log response status and body when debug mode is enabled. +/// Consumes the response and returns the status + body text for the caller to parse. +pub fn debug_response(resp: reqwest::blocking::Response) -> (reqwest::StatusCode, String) { + let status = resp.status(); + let body = resp.text().unwrap_or_default(); + + if is_debug() { + use crossterm::style::Stylize; + let status_str = format!("<<< {} {}", status.as_u16(), status.canonical_reason().unwrap_or("")); + if status.is_success() { + eprintln!("{}", status_str.dark_green()); + } else { + eprintln!("{}", status_str.dark_red()); + } + if let Ok(v) = serde_json::from_str::(&body) { + eprintln!("{}", colorize_json(&serde_json::to_string_pretty(&v).unwrap())); + } else if !body.is_empty() { + eprintln!("{}", body.to_string().dark_grey()); + } + } + + (status, body) +} + +/// Colorize a pretty-printed JSON string for terminal output. +fn colorize_json(json: &str) -> String { + use crossterm::style::Stylize; + let mut result = String::with_capacity(json.len() * 2); + + for line in json.lines() { + let trimmed = line.trim_start(); + + if trimmed.starts_with('"') { + // Key-value line or string in array + if let Some(colon_pos) = find_key_colon(trimmed) { + // Key: value line + let indent = &line[..line.len() - trimmed.len()]; + let key = &trimmed[..colon_pos]; + let sep = ": "; + let value = trimmed[colon_pos + 2..].trim(); + result.push_str(indent); + result.push_str(&key.dark_cyan().to_string()); + result.push_str(&sep.dark_grey().to_string()); + result.push_str(&colorize_json_value(value)); + } else { + // String value in array + result.push_str(&line.yellow().to_string()); + } + } else if trimmed.starts_with('{') || trimmed.starts_with('}') + || trimmed.starts_with('[') || trimmed.starts_with(']') + { + result.push_str(&line.dark_grey().to_string()); + } else { + // Bare value in array + let indent = &line[..line.len() - trimmed.len()]; + result.push_str(indent); + result.push_str(&colorize_json_value(trimmed)); + } + result.push('\n'); + } + + // Remove trailing newline + if result.ends_with('\n') { + result.pop(); + } + result +} + +/// Find the colon separating a JSON key from its value, skipping the key string. +fn find_key_colon(s: &str) -> Option { + // Expect: "key": value + if !s.starts_with('"') { return None; } + let mut i = 1; + let bytes = s.as_bytes(); + while i < bytes.len() { + if bytes[i] == b'\\' { i += 2; continue; } + if bytes[i] == b'"' { + // Found end of key, look for ": " + if s.get(i + 1..i + 3) == Some(": ") { + return Some(i + 1); + } + return None; + } + i += 1; + } + None +} + +/// Colorize a JSON value (right side of colon, or bare array element). +fn colorize_json_value(v: &str) -> String { + use crossterm::style::Stylize; + let stripped = v.trim_end_matches(','); + let comma = if v.ends_with(',') { ",".dark_grey().to_string() } else { String::new() }; + + let colored = if stripped == "null" { + stripped.dark_grey().to_string() + } else if stripped == "true" || stripped == "false" { + stripped.yellow().to_string() + } else if stripped.starts_with('"') { + stripped.green().to_string() + } else { + // number + stripped.cyan().to_string() + }; + + format!("{colored}{comma}") +} + /// Format an ISO date string compactly: "2024-03-15 14:23" (no seconds, no timezone). pub fn format_date(s: &str) -> String { let s = s.split('.').next().unwrap_or(s).trim_end_matches('Z'); diff --git a/src/workspace.rs b/src/workspace.rs index da2c29b..719a7aa 100644 --- a/src/workspace.rs +++ b/src/workspace.rs @@ -1,3 +1,4 @@ +use crate::api::ApiClient; use crate::config; use serde::{Deserialize, Serialize}; @@ -15,54 +16,10 @@ struct ListResponse { workspaces: Vec, } -fn load_client() -> (reqwest::blocking::Client, String, String) { - let profile_config = match config::load("default") { - Ok(c) => c, - Err(e) => { - eprintln!("{e}"); - std::process::exit(1); - } - }; - let api_key = match &profile_config.api_key { - Some(key) if key != "PLACEHOLDER" => key.clone(), - _ => { - eprintln!("error: not authenticated. Run 'hotdata auth' to log in."); - std::process::exit(1); - } - }; - let api_url = profile_config.api_url.to_string(); - (reqwest::blocking::Client::new(), api_key, api_url) -} - -fn fetch_all_workspaces(client: &reqwest::blocking::Client, api_key: &str, api_url: &str) -> Vec { - let url = format!("{api_url}/workspaces"); - let resp = match client - .get(&url) - .header("Authorization", format!("Bearer {api_key}")) - .send() - { - Ok(r) => r, - Err(e) => { - eprintln!("error connecting to API: {e}"); - std::process::exit(1); - } - }; - if !resp.status().is_success() { - eprintln!("error: {}", crate::util::api_error(resp.text().unwrap_or_default())); - std::process::exit(1); - } - match resp.json::() { - Ok(b) => b.workspaces, - Err(e) => { - eprintln!("error parsing response: {e}"); - std::process::exit(1); - } - } -} - pub fn set(workspace_id: Option<&str>) { - let (client, api_key, api_url) = load_client(); - let workspaces = fetch_all_workspaces(&client, &api_key, &api_url); + let api = ApiClient::new(None); + let body: ListResponse = api.get("/workspaces"); + let workspaces = body.workspaces; let chosen = match workspace_id { Some(id) => { @@ -111,44 +68,10 @@ pub fn list(format: &str) { std::process::exit(1); } }; - - let api_key = match &profile_config.api_key { - Some(key) if key != "PLACEHOLDER" => key.clone(), - _ => { - eprintln!("error: not authenticated. Run 'hotdata auth' to log in."); - std::process::exit(1); - } - }; - let default_id = profile_config.workspaces.first().map(|w| w.public_id.as_str()).unwrap_or("").to_string(); - let url = format!("{}/workspaces", profile_config.api_url); - let client = reqwest::blocking::Client::new(); - - let resp = match client - .get(&url) - .header("Authorization", format!("Bearer {api_key}")) - .send() - { - Ok(r) => r, - Err(e) => { - eprintln!("error connecting to API: {e}"); - std::process::exit(1); - } - }; - - if !resp.status().is_success() { - eprintln!("error: {}", crate::util::api_error(resp.text().unwrap_or_default())); - std::process::exit(1); - } - - let body: ListResponse = match resp.json() { - Ok(b) => b, - Err(e) => { - eprintln!("error parsing response: {e}"); - std::process::exit(1); - } - }; + let api = ApiClient::new(None); + let body: ListResponse = api.get("/workspaces"); match format { "json" => {