feat: add interactive import
55966b71
2 file(s) · +229 −13
| 10 | 10 | ||
| 11 | 11 | ## Features |
|
| 12 | 12 | ||
| 13 | - | - **OpenAPI 3.x import** — turn a spec (file or URL) into a collection with requests, params, example bodies, servers, and docs prefilled. |
|
| 13 | + | - **OpenAPI 3.x import** — turn a spec (file or URL) into a collection with requests, params, example bodies, servers, and docs prefilled. Import walks you through auth (bearer / api key / oauth2), picking an active server, and display preferences; pass `-y` to skip it. |
|
| 14 | 14 | - **Ad-hoc collections** — no spec needed; paste a full URL and it's split into server, path, and query params for you. |
|
| 15 | 15 | - **Vim-style TUI** — three panes (requests / editor / response), `j`/`k` navigation, `:` command line, `/` incremental search. |
|
| 16 | 16 | - **Variables** — `{{name}}` from the collection, plus dynamic ones like `{{uuid}}`, `{{timestamp}}`, `{{randomInt(1,100)}}`. |
|
| 60 | 60 | ```sh |
|
| 61 | 61 | cielago # open the last-used collection in the TUI |
|
| 62 | 62 | cielago open [name] # open a specific collection |
|
| 63 | - | cielago import <spec|url> # import an OpenAPI 3.x spec |
|
| 63 | + | cielago import <spec|url> [-y] # import an OpenAPI 3.x spec (-y skips setup) |
|
| 64 | 64 | cielago new <name> [--server u] # create an empty collection and open it |
|
| 65 | 65 | cielago list [-l] # list collections (-l adds counts + paths) |
|
| 66 | 66 | cielago info <name> # servers, counts, auth, groups |
|
| 1 | 1 | use std::collections::BTreeMap; |
|
| 2 | 2 | use std::fs; |
|
| 3 | - | use std::io::{self, Write}; |
|
| 3 | + | use std::io::{self, IsTerminal, Write}; |
|
| 4 | 4 | use std::process::Command as ProcessCommand; |
|
| 5 | 5 | ||
| 6 | 6 | use anyhow::{Context, Result, bail}; |
|
| 7 | 7 | use clap::{Parser, Subcommand}; |
|
| 8 | 8 | ||
| 9 | 9 | use cielago::app; |
|
| 10 | - | use cielago::model::Collection; |
|
| 10 | + | use cielago::model::{ |
|
| 11 | + | AuthKind, AuthStyle, Collection, DEFAULT_API_KEY_HEADER, LabelMode, OAuthConfig, |
|
| 12 | + | }; |
|
| 11 | 13 | use cielago::openapi; |
|
| 12 | 14 | use cielago::store::{self, AppConfig}; |
|
| 13 | 15 | ||
| 43 | 45 | /// Collection name (defaults to the spec's info.title) |
|
| 44 | 46 | #[arg(long)] |
|
| 45 | 47 | name: Option<String>, |
|
| 48 | + | /// Skip interactive setup; keep spec-derived auth/servers and defaults |
|
| 49 | + | #[arg(short = 'y', long)] |
|
| 50 | + | yes: bool, |
|
| 46 | 51 | }, |
|
| 47 | 52 | /// Create an empty collection and open it in the TUI |
|
| 48 | 53 | New { |
|
| 88 | 93 | async fn main() -> Result<()> { |
|
| 89 | 94 | let cli = Cli::parse(); |
|
| 90 | 95 | match cli.command { |
|
| 91 | - | Some(Command::Import { source, name }) => cmd_import(&source, name).await, |
|
| 96 | + | Some(Command::Import { source, name, yes }) => cmd_import(&source, name, yes).await, |
|
| 92 | 97 | Some(Command::New { name, server }) => cmd_new(&name, server).await, |
|
| 93 | 98 | Some(Command::List { long }) => cmd_list(long), |
|
| 94 | 99 | Some(Command::Open { name }) => cmd_open(name).await, |
|
| 102 | 107 | } |
|
| 103 | 108 | } |
|
| 104 | 109 | ||
| 105 | - | async fn cmd_import(source: &str, name: Option<String>) -> Result<()> { |
|
| 110 | + | async fn cmd_import(source: &str, name: Option<String>, yes: bool) -> Result<()> { |
|
| 106 | 111 | let doc = openapi::load_spec(source).await?; |
|
| 107 | 112 | let name = name |
|
| 108 | 113 | .or_else(|| { |
|
| 112 | 117 | }) |
|
| 113 | 118 | .unwrap_or_else(|| "imported".to_string()); |
|
| 114 | 119 | ||
| 115 | - | let collection = openapi::import_spec(&doc, &name, Some(source.to_string())); |
|
| 120 | + | // Walk the user through the name, auth, server and display preferences |
|
| 121 | + | // unless they opted out with `--yes` or stdin isn't a terminal (e.g. a |
|
| 122 | + | // script or pipe), in which case the spec-derived defaults stand. |
|
| 123 | + | let interactive = !yes && io::stdin().is_terminal(); |
|
| 124 | + | let name = if interactive { |
|
| 125 | + | ask_default("\nCollection name", &name)? |
|
| 126 | + | } else { |
|
| 127 | + | name |
|
| 128 | + | }; |
|
| 129 | + | ||
| 130 | + | let mut collection = openapi::import_spec(&doc, &name, Some(source.to_string())); |
|
| 131 | + | ||
| 132 | + | if interactive { |
|
| 133 | + | prompt_auth(&mut collection)?; |
|
| 134 | + | prompt_server(&mut collection)?; |
|
| 135 | + | prompt_preferences(&mut collection)?; |
|
| 136 | + | } |
|
| 137 | + | ||
| 116 | 138 | let path = store::save_collection(&collection)?; |
|
| 117 | 139 | ||
| 118 | 140 | println!( |
|
| 121 | 143 | path.display() |
|
| 122 | 144 | ); |
|
| 123 | 145 | println!(" {} requests", collection.requests.len()); |
|
| 124 | - | if !collection.servers.is_empty() { |
|
| 125 | - | println!(" servers: {}", collection.servers.join(", ")); |
|
| 126 | - | } |
|
| 127 | - | if let Some(auth) = &collection.auth { |
|
| 146 | + | if collection.servers.is_empty() { |
|
| 147 | + | println!(" servers: (none)"); |
|
| 148 | + | } else { |
|
| 128 | 149 | println!( |
|
| 129 | - | " oauth2 client-credentials: {} (set client id/secret with A in the TUI)", |
|
| 130 | - | auth.token_url |
|
| 150 | + | " servers: {} (active: {})", |
|
| 151 | + | collection.servers.join(", "), |
|
| 152 | + | collection |
|
| 153 | + | .base_url() |
|
| 154 | + | .unwrap_or(collection.servers[0].as_str()) |
|
| 131 | 155 | ); |
|
| 132 | 156 | } |
|
| 157 | + | match &collection.auth { |
|
| 158 | + | Some(auth) => println!(" auth: {}", auth_summary(auth)), |
|
| 159 | + | None => println!(" auth: none"), |
|
| 160 | + | } |
|
| 161 | + | Ok(()) |
|
| 162 | + | } |
|
| 163 | + | ||
| 164 | + | /// One-line description of a configured auth scheme for the import summary. |
|
| 165 | + | fn auth_summary(auth: &OAuthConfig) -> String { |
|
| 166 | + | match auth.kind { |
|
| 167 | + | AuthKind::Bearer => { |
|
| 168 | + | let state = if auth.token.is_empty() { |
|
| 169 | + | " (no token set — set it with A in the TUI)" |
|
| 170 | + | } else { |
|
| 171 | + | "" |
|
| 172 | + | }; |
|
| 173 | + | format!("bearer{state}") |
|
| 174 | + | } |
|
| 175 | + | AuthKind::ApiKey => { |
|
| 176 | + | let state = if auth.token.is_empty() { |
|
| 177 | + | " (no value set — set it with A in the TUI)" |
|
| 178 | + | } else { |
|
| 179 | + | "" |
|
| 180 | + | }; |
|
| 181 | + | format!("api key in {}{state}", auth.api_key_header()) |
|
| 182 | + | } |
|
| 183 | + | AuthKind::Oauth2 => { |
|
| 184 | + | let state = if auth.client_id.is_empty() { |
|
| 185 | + | " (set client id/secret with A in the TUI)" |
|
| 186 | + | } else { |
|
| 187 | + | "" |
|
| 188 | + | }; |
|
| 189 | + | format!("oauth2 client-credentials, token url {}{state}", auth.token_url) |
|
| 190 | + | } |
|
| 191 | + | } |
|
| 192 | + | } |
|
| 193 | + | ||
| 194 | + | /// Print `prompt`, then read one trimmed line from stdin. An empty reply (or |
|
| 195 | + | /// EOF) comes back as an empty string, which every caller treats as "leave the |
|
| 196 | + | /// current value" or "skip". |
|
| 197 | + | fn ask(prompt: &str) -> Result<String> { |
|
| 198 | + | print!("{prompt}"); |
|
| 199 | + | io::stdout().flush()?; |
|
| 200 | + | let mut line = String::new(); |
|
| 201 | + | io::stdin().read_line(&mut line)?; |
|
| 202 | + | Ok(line.trim().to_string()) |
|
| 203 | + | } |
|
| 204 | + | ||
| 205 | + | /// Like [`ask`], but shows `current` as the value kept when the reply is blank. |
|
| 206 | + | fn ask_default(label: &str, current: &str) -> Result<String> { |
|
| 207 | + | let suffix = if current.is_empty() { |
|
| 208 | + | String::new() |
|
| 209 | + | } else { |
|
| 210 | + | format!(" [{current}]") |
|
| 211 | + | }; |
|
| 212 | + | let answer = ask(&format!("{label}{suffix}: "))?; |
|
| 213 | + | Ok(if answer.is_empty() { |
|
| 214 | + | current.to_string() |
|
| 215 | + | } else { |
|
| 216 | + | answer |
|
| 217 | + | }) |
|
| 218 | + | } |
|
| 219 | + | ||
| 220 | + | /// Choose the collection's auth scheme, then collect that scheme's values. Any |
|
| 221 | + | /// value may be left blank and filled in later from the TUI. A scheme the spec |
|
| 222 | + | /// implied (e.g. oauth2 from a `clientCredentials` flow) is offered as default |
|
| 223 | + | /// and seeds the oauth2 field prompts. |
|
| 224 | + | fn prompt_auth(collection: &mut Collection) -> Result<()> { |
|
| 225 | + | let detected = collection.auth.as_ref().map(|a| a.kind); |
|
| 226 | + | println!("\nAuthentication:"); |
|
| 227 | + | println!(" 1) none"); |
|
| 228 | + | println!(" 2) bearer token"); |
|
| 229 | + | println!(" 3) api key"); |
|
| 230 | + | println!(" 4) oauth2 client-credentials"); |
|
| 231 | + | let default_choice = match detected { |
|
| 232 | + | Some(AuthKind::Bearer) => 2, |
|
| 233 | + | Some(AuthKind::ApiKey) => 3, |
|
| 234 | + | Some(AuthKind::Oauth2) => 4, |
|
| 235 | + | None => 1, |
|
| 236 | + | }; |
|
| 237 | + | let raw = ask(&format!(" choose [1-4] (default {default_choice}): "))?; |
|
| 238 | + | let choice = if raw.is_empty() { |
|
| 239 | + | default_choice |
|
| 240 | + | } else { |
|
| 241 | + | raw.parse().unwrap_or(default_choice) |
|
| 242 | + | }; |
|
| 243 | + | ||
| 244 | + | collection.auth = match choice { |
|
| 245 | + | 2 => { |
|
| 246 | + | let token = ask(" bearer token (blank to set later): ")?; |
|
| 247 | + | Some(OAuthConfig { |
|
| 248 | + | kind: AuthKind::Bearer, |
|
| 249 | + | token, |
|
| 250 | + | ..Default::default() |
|
| 251 | + | }) |
|
| 252 | + | } |
|
| 253 | + | 3 => { |
|
| 254 | + | let header = ask(&format!( |
|
| 255 | + | " header name (blank for {DEFAULT_API_KEY_HEADER}): " |
|
| 256 | + | ))?; |
|
| 257 | + | let token = ask(" api key value (blank to set later): ")?; |
|
| 258 | + | Some(OAuthConfig { |
|
| 259 | + | kind: AuthKind::ApiKey, |
|
| 260 | + | header, |
|
| 261 | + | token, |
|
| 262 | + | ..Default::default() |
|
| 263 | + | }) |
|
| 264 | + | } |
|
| 265 | + | 4 => { |
|
| 266 | + | // Reuse spec-derived token url/scopes as defaults when present. |
|
| 267 | + | let mut cfg = collection.auth.clone().unwrap_or_default(); |
|
| 268 | + | cfg.kind = AuthKind::Oauth2; |
|
| 269 | + | cfg.token_url = ask_default(" token url", &cfg.token_url)?; |
|
| 270 | + | cfg.client_id = ask(" client id (blank to set later): ")?; |
|
| 271 | + | cfg.client_secret = ask(" client secret (blank to set later): ")?; |
|
| 272 | + | let scopes = ask_default(" scopes (space-separated)", &cfg.scopes.join(" "))?; |
|
| 273 | + | cfg.scopes = scopes.split_whitespace().map(String::from).collect(); |
|
| 274 | + | let style = ask(" send credentials via [1] basic header or [2] form body (default 1): ")?; |
|
| 275 | + | cfg.auth_style = if style == "2" { |
|
| 276 | + | AuthStyle::Post |
|
| 277 | + | } else { |
|
| 278 | + | AuthStyle::Basic |
|
| 279 | + | }; |
|
| 280 | + | Some(cfg) |
|
| 281 | + | } |
|
| 282 | + | _ => None, |
|
| 283 | + | }; |
|
| 284 | + | Ok(()) |
|
| 285 | + | } |
|
| 286 | + | ||
| 287 | + | /// Pick the active base URL. Spec-derived servers are offered by number; a |
|
| 288 | + | /// typed URL is added and made active; a blank reply keeps the first (or none). |
|
| 289 | + | fn prompt_server(collection: &mut Collection) -> Result<()> { |
|
| 290 | + | println!("\nServer:"); |
|
| 291 | + | if collection.servers.is_empty() { |
|
| 292 | + | let url = normalize_server(&ask(" base url (blank for none): ")?); |
|
| 293 | + | if !url.is_empty() { |
|
| 294 | + | collection.servers.push(url); |
|
| 295 | + | collection.active_server = 0; |
|
| 296 | + | } |
|
| 297 | + | return Ok(()); |
|
| 298 | + | } |
|
| 299 | + | ||
| 300 | + | println!(" detected:"); |
|
| 301 | + | for (i, s) in collection.servers.iter().enumerate() { |
|
| 302 | + | println!(" {}) {s}", i + 1); |
|
| 303 | + | } |
|
| 304 | + | let answer = ask(" choose a number, type a new url, or blank for #1: ")?; |
|
| 305 | + | if answer.is_empty() { |
|
| 306 | + | collection.active_server = 0; |
|
| 307 | + | } else if let Ok(n) = answer.parse::<usize>() { |
|
| 308 | + | if (1..=collection.servers.len()).contains(&n) { |
|
| 309 | + | collection.active_server = n - 1; |
|
| 310 | + | } |
|
| 311 | + | } else { |
|
| 312 | + | let url = normalize_server(&answer); |
|
| 313 | + | let idx = collection |
|
| 314 | + | .servers |
|
| 315 | + | .iter() |
|
| 316 | + | .position(|s| *s == url) |
|
| 317 | + | .unwrap_or_else(|| { |
|
| 318 | + | collection.servers.push(url); |
|
| 319 | + | collection.servers.len() - 1 |
|
| 320 | + | }); |
|
| 321 | + | collection.active_server = idx; |
|
| 322 | + | } |
|
| 323 | + | Ok(()) |
|
| 324 | + | } |
|
| 325 | + | ||
| 326 | + | /// Trim surrounding whitespace and a trailing slash so a typed URL matches the |
|
| 327 | + | /// form imported servers are stored in (and doesn't duplicate one). |
|
| 328 | + | fn normalize_server(url: &str) -> String { |
|
| 329 | + | url.trim().trim_end_matches('/').to_string() |
|
| 330 | + | } |
|
| 331 | + | ||
| 332 | + | /// Collection display preferences: how the sidebar labels requests, and whether |
|
| 333 | + | /// tag groups start collapsed. |
|
| 334 | + | fn prompt_preferences(collection: &mut Collection) -> Result<()> { |
|
| 335 | + | println!("\nPreferences:"); |
|
| 336 | + | println!(" label requests by:"); |
|
| 337 | + | println!(" 1) name"); |
|
| 338 | + | println!(" 2) summary"); |
|
| 339 | + | println!(" 3) path"); |
|
| 340 | + | let answer = ask(" choose [1-3] (default 1): ")?; |
|
| 341 | + | collection.label_mode = match answer.as_str() { |
|
| 342 | + | "2" => LabelMode::Summary, |
|
| 343 | + | "3" => LabelMode::Path, |
|
| 344 | + | _ => LabelMode::Name, |
|
| 345 | + | }; |
|
| 346 | + | ||
| 347 | + | let answer = ask(" collapse tag groups on open? [y/N]: ")?; |
|
| 348 | + | collection.groups_collapsed = matches!(answer.to_ascii_lowercase().as_str(), "y" | "yes"); |
|
| 133 | 349 | Ok(()) |
|
| 134 | 350 | } |
|
| 135 | 351 | ||