diff --git a/src/api.rs b/src/api.rs index 4824af9..50271c0 100644 --- a/src/api.rs +++ b/src/api.rs @@ -3,6 +3,27 @@ use crate::config; use crate::util; use crossterm::style::Stylize; use serde::de::DeserializeOwned; +use std::time::Duration; + +/// Cap on any single HTTP request. Connection create + synchronous schema +/// discovery against a slow remote catalog can take well over a minute, so +/// this needs to be generous; 5 minutes leaves headroom while still bounding +/// the worst case if the server genuinely hangs. +const HTTP_REQUEST_TIMEOUT: Duration = Duration::from_secs(300); + +/// TCP keepalive cadence. Without this, macOS will drop a TCP connection +/// that has been quiet (e.g. while the server is doing slow synchronous +/// work) and reqwest surfaces it as "error sending request" even though the +/// request itself completed server-side. +const TCP_KEEPALIVE_INTERVAL: Duration = Duration::from_secs(30); + +fn build_http_client() -> reqwest::blocking::Client { + reqwest::blocking::Client::builder() + .timeout(HTTP_REQUEST_TIMEOUT) + .tcp_keepalive(TCP_KEEPALIVE_INTERVAL) + .build() + .expect("reqwest blocking client should always build with these defaults") +} #[derive(Clone)] pub struct ApiClient { @@ -48,7 +69,7 @@ impl ApiClient { }; Self { - client: reqwest::blocking::Client::new(), + client: build_http_client(), api_key: access_token, api_url: profile_config.api_url.to_string(), workspace_id: workspace_id.map(String::from), @@ -66,7 +87,7 @@ impl ApiClient { #[cfg(test)] pub(crate) fn test_new(api_url: &str, api_key: &str, workspace_id: Option<&str>) -> Self { Self { - client: reqwest::blocking::Client::new(), + client: build_http_client(), api_key: api_key.to_string(), api_url: api_url.to_string(), workspace_id: workspace_id.map(String::from), diff --git a/src/connections_new.rs b/src/connections_new.rs index da8a5ef..4a231df 100644 --- a/src/connections_new.rs +++ b/src/connections_new.rs @@ -113,14 +113,31 @@ fn prompt_field(key: &str, field: &Value, is_required: bool) -> Option { let field_type = field["type"].as_str().unwrap_or("string"); let format = field["format"].as_str().unwrap_or(""); + let description = field["description"].as_str(); + // Accept both string and integer examples — `as_str` alone would silently + // miss schemas like `"examples": [8080]` on integer fields. + let example: Option = field["examples"] + .as_array() + .and_then(|a| a.first()) + .and_then(|v| { + v.as_str() + .map(str::to_owned) + .or_else(|| v.as_i64().map(|n| n.to_string())) + }); let opt_hint = "optional — press Enter to skip"; + let help_message: Option = match (description, is_required) { + (Some(d), true) => Some(d.to_string()), + (Some(d), false) => Some(format!("{d} ({opt_hint})")), + (None, true) => None, + (None, false) => Some(opt_hint.to_string()), + }; match (field_type, format) { ("string", "password") => { let label = format!("{key}:"); let mut p = Password::new(&label).without_confirmation(); - if !is_required { - p = p.with_help_message(opt_hint); + if let Some(h) = &help_message { + p = p.with_help_message(h); } let val = p.prompt().unwrap_or_else(|_| std::process::exit(0)); if val.is_empty() && !is_required { @@ -133,11 +150,14 @@ fn prompt_field(key: &str, field: &Value, is_required: bool) -> Option { ("string", _) => { let label = format!("{key}:"); let mut t = Text::new(&label); - if let Some(default) = field["default"].as_str() { - t = t.with_default(default); + let default = field["default"].as_str(); + if let Some(d) = default { + t = t.with_default(d); + } else if let Some(e) = example.as_deref() { + t = t.with_placeholder(e); } - if !is_required { - t = t.with_help_message(opt_hint); + if let Some(h) = &help_message { + t = t.with_help_message(h); } let val = t.prompt().unwrap_or_else(|_| std::process::exit(0)); if val.is_empty() && !is_required { @@ -149,7 +169,7 @@ fn prompt_field(key: &str, field: &Value, is_required: bool) -> Option { ("integer", _) => { let label = format!("{key}:"); - let t = Text::new(&label).with_validator(move |input: &str| { + let mut t = Text::new(&label).with_validator(move |input: &str| { if input.is_empty() { if is_required { return Ok(Validation::Invalid("This field is required".into())); @@ -162,13 +182,12 @@ fn prompt_field(key: &str, field: &Value, is_required: bool) -> Option { Ok(Validation::Invalid("Must be a whole number".into())) } }); - let help_t; - let t = if !is_required { - help_t = t.with_help_message(opt_hint); - help_t - } else { - t - }; + if let Some(e) = example.as_deref() { + t = t.with_placeholder(e); + } + if let Some(h) = &help_message { + t = t.with_help_message(h); + } let val = t.prompt().unwrap_or_else(|_| std::process::exit(0)); if val.is_empty() && !is_required { None @@ -182,23 +201,28 @@ fn prompt_field(key: &str, field: &Value, is_required: bool) -> Option { ("boolean", _) => { let label = format!("{key}:"); let default = field["default"].as_bool().unwrap_or(false); - let val = Confirm::new(&label) - .with_default(default) - .prompt() - .unwrap_or_else(|_| std::process::exit(0)); + let mut c = Confirm::new(&label).with_default(default); + if let Some(h) = &help_message { + c = c.with_help_message(h); + } + let val = c.prompt().unwrap_or_else(|_| std::process::exit(0)); Some(Value::Bool(val)) } ("array", _) => { let label = format!("{key}:"); - let help = if is_required { + let array_hint = if is_required { "Enter values separated by commas" } else { "Enter values separated by commas — optional, press Enter to skip" }; + let help = match description { + Some(d) => format!("{d} — {array_hint}"), + None => array_hint.to_string(), + }; let val = Text::new(&label) - .with_placeholder("value1, value2, ...") - .with_help_message(help) + .with_placeholder(example.as_deref().unwrap_or("value1, value2, ...")) + .with_help_message(&help) .prompt() .unwrap_or_else(|_| std::process::exit(0)); if val.is_empty() && !is_required {