| 1 | use std::collections::HashMap; |
| 2 | use std::fmt; |
| 3 | |
| 4 | use serde::{Deserialize, Serialize}; |
| 5 | use uuid::Uuid; |
| 6 | |
| 7 | // The saved view records which pane and editor tab were open, so the two |
| 8 | // enums that describe them are re-used here rather than mirrored. |
| 9 | use crate::app::{EditorTab, Focus}; |
| 10 | |
| 11 | /// Collection-level `{{variables}}` as an ordered, editable list. |
| 12 | pub type Variables = Vec<KeyValueRow>; |
| 13 | |
| 14 | pub fn variables_map(vars: &Variables) -> HashMap<String, String> { |
| 15 | vars.iter() |
| 16 | .filter(|r| r.enabled && !r.key.is_empty()) |
| 17 | .map(|r| (r.key.clone(), r.value.clone())) |
| 18 | .collect() |
| 19 | } |
| 20 | |
| 21 | /// Names inside single `{…}` in a path template, deduped, in order of first |
| 22 | /// appearance. `{{var}}` is variable syntax rather than a path param and is |
| 23 | /// skipped, so `/{{tenant}}/pets/{petId}` yields just `petId`. |
| 24 | pub fn path_placeholders(path: &str) -> Vec<String> { |
| 25 | let bytes = path.as_bytes(); |
| 26 | let mut names: Vec<String> = Vec::new(); |
| 27 | let mut i = 0; |
| 28 | while i < bytes.len() { |
| 29 | if bytes[i] != b'{' { |
| 30 | i += 1; |
| 31 | continue; |
| 32 | } |
| 33 | // `{{…}}` is a variable; skip past its closing braces entirely. |
| 34 | if bytes.get(i + 1) == Some(&b'{') { |
| 35 | match path[i + 2..].find("}}") { |
| 36 | Some(off) => i += 2 + off + 2, |
| 37 | None => break, |
| 38 | } |
| 39 | continue; |
| 40 | } |
| 41 | let Some(off) = path[i + 1..].find('}') else { |
| 42 | break; |
| 43 | }; |
| 44 | let name = path[i + 1..i + 1 + off].trim(); |
| 45 | if !name.is_empty() && !name.contains('/') && !names.iter().any(|n| n == name) { |
| 46 | names.push(name.to_string()); |
| 47 | } |
| 48 | i += 1 + off + 1; |
| 49 | } |
| 50 | names |
| 51 | } |
| 52 | |
| 53 | #[derive(Debug, Clone, Serialize, Deserialize)] |
| 54 | pub struct Collection { |
| 55 | pub name: String, |
| 56 | /// Path or URL the spec was imported from, if any. |
| 57 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 58 | pub spec_source: Option<String>, |
| 59 | #[serde(default)] |
| 60 | pub servers: Vec<String>, |
| 61 | #[serde(default)] |
| 62 | pub active_server: usize, |
| 63 | #[serde(default)] |
| 64 | pub variables: Variables, |
| 65 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 66 | pub auth: Option<OAuthConfig>, |
| 67 | /// How the sidebar labels requests; persisted with the collection. |
| 68 | #[serde(default)] |
| 69 | pub label_mode: LabelMode, |
| 70 | /// Whether tag groups start collapsed when the collection is opened. |
| 71 | #[serde(default)] |
| 72 | pub groups_collapsed: bool, |
| 73 | /// The request that was open the last time the collection was saved, so |
| 74 | /// reopening it lands back on the same page. `None` for collections saved |
| 75 | /// before this existed, or saved with nothing selected. |
| 76 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 77 | pub last_request: Option<Uuid>, |
| 78 | /// Pane (`1`/`2`/`3`) that had focus at the last save. |
| 79 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 80 | pub last_focus: Option<Focus>, |
| 81 | /// Editor tab that was open at the last save. |
| 82 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 83 | pub last_tab: Option<EditorTab>, |
| 84 | #[serde(default)] |
| 85 | pub requests: Vec<SavedRequest>, |
| 86 | } |
| 87 | |
| 88 | impl Collection { |
| 89 | pub fn new(name: impl Into<String>) -> Self { |
| 90 | Self { |
| 91 | name: name.into(), |
| 92 | spec_source: None, |
| 93 | servers: Vec::new(), |
| 94 | active_server: 0, |
| 95 | variables: Vec::new(), |
| 96 | auth: None, |
| 97 | label_mode: LabelMode::default(), |
| 98 | groups_collapsed: false, |
| 99 | last_request: None, |
| 100 | last_focus: None, |
| 101 | last_tab: None, |
| 102 | requests: Vec::new(), |
| 103 | } |
| 104 | } |
| 105 | |
| 106 | pub fn base_url(&self) -> Option<&str> { |
| 107 | self.servers.get(self.active_server).map(|s| s.as_str()) |
| 108 | } |
| 109 | |
| 110 | /// Take the routes from a freshly imported collection, overwriting this |
| 111 | /// collection's `requests` while leaving auth, variables, servers, active |
| 112 | /// server and view state as they were. `last_request` is dropped because |
| 113 | /// import mints new request ids, so an old pointer would dangle. |
| 114 | pub fn replace_requests_from(&mut self, imported: Collection) { |
| 115 | self.requests = imported.requests; |
| 116 | self.last_request = None; |
| 117 | } |
| 118 | } |
| 119 | |
| 120 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] |
| 121 | #[serde(rename_all = "lowercase")] |
| 122 | pub enum AuthStyle { |
| 123 | /// client_id/client_secret sent via HTTP Basic header (RFC 6749 default). |
| 124 | #[default] |
| 125 | Basic, |
| 126 | /// client_id/client_secret sent in the form body. |
| 127 | Post, |
| 128 | } |
| 129 | |
| 130 | /// Which authentication scheme a collection uses. `Oauth2` is the default so |
| 131 | /// collections written before bearer/api-key support (their `auth` object has |
| 132 | /// no `kind`) keep loading as the client-credentials config they were. |
| 133 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] |
| 134 | #[serde(rename_all = "lowercase")] |
| 135 | pub enum AuthKind { |
| 136 | #[default] |
| 137 | Oauth2, |
| 138 | /// A fixed bearer token sent as `Authorization: Bearer <token>`. |
| 139 | Bearer, |
| 140 | /// A fixed value sent in an arbitrary header (defaults to `X-API-Key`). |
| 141 | #[serde(rename = "apikey")] |
| 142 | ApiKey, |
| 143 | } |
| 144 | |
| 145 | impl AuthKind { |
| 146 | pub const ALL: [AuthKind; 3] = [AuthKind::Bearer, AuthKind::ApiKey, AuthKind::Oauth2]; |
| 147 | |
| 148 | pub fn next(self) -> Self { |
| 149 | let i = Self::ALL.iter().position(|k| *k == self).unwrap_or(0); |
| 150 | Self::ALL[(i + 1) % Self::ALL.len()] |
| 151 | } |
| 152 | |
| 153 | pub fn title(self) -> &'static str { |
| 154 | match self { |
| 155 | AuthKind::Oauth2 => "oauth2", |
| 156 | AuthKind::Bearer => "bearer", |
| 157 | AuthKind::ApiKey => "apikey", |
| 158 | } |
| 159 | } |
| 160 | } |
| 161 | |
| 162 | /// Header used for API-key auth when the user leaves the header name blank. |
| 163 | pub const DEFAULT_API_KEY_HEADER: &str = "X-API-Key"; |
| 164 | |
| 165 | /// Per-collection authentication. One struct carries every scheme's fields |
| 166 | /// (discriminated by `kind`) so the on-disk shape stays a single flat object |
| 167 | /// and older OAuth-only collections deserialize unchanged. |
| 168 | /// |
| 169 | /// Secret-bearing fields (`token`, `client_secret`) may hold a `$(…)` command |
| 170 | /// substitution, resolved at send time — see [`crate::http::resolve_secret`]. |
| 171 | #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] |
| 172 | pub struct OAuthConfig { |
| 173 | #[serde(default)] |
| 174 | pub kind: AuthKind, |
| 175 | /// Bearer token, or API-key value (secret; supports `$(…)`). |
| 176 | #[serde(default)] |
| 177 | pub token: String, |
| 178 | /// Header name for `ApiKey` auth; empty means [`DEFAULT_API_KEY_HEADER`]. |
| 179 | #[serde(default)] |
| 180 | pub header: String, |
| 181 | #[serde(default)] |
| 182 | pub token_url: String, |
| 183 | #[serde(default)] |
| 184 | pub client_id: String, |
| 185 | #[serde(default)] |
| 186 | pub client_secret: String, |
| 187 | #[serde(default)] |
| 188 | pub scopes: Vec<String>, |
| 189 | #[serde(default)] |
| 190 | pub auth_style: AuthStyle, |
| 191 | } |
| 192 | |
| 193 | /// Historical name; `OAuthConfig` now covers every scheme via its `kind`. |
| 194 | pub type AuthConfig = OAuthConfig; |
| 195 | |
| 196 | impl OAuthConfig { |
| 197 | /// Whether the active scheme has enough filled in to attempt auth. |
| 198 | pub fn is_configured(&self) -> bool { |
| 199 | match self.kind { |
| 200 | AuthKind::Oauth2 => !self.token_url.is_empty() && !self.client_id.is_empty(), |
| 201 | AuthKind::Bearer | AuthKind::ApiKey => !self.token.is_empty(), |
| 202 | } |
| 203 | } |
| 204 | |
| 205 | /// The header name to carry an API key, falling back to the default. |
| 206 | pub fn api_key_header(&self) -> &str { |
| 207 | if self.header.trim().is_empty() { |
| 208 | DEFAULT_API_KEY_HEADER |
| 209 | } else { |
| 210 | self.header.trim() |
| 211 | } |
| 212 | } |
| 213 | } |
| 214 | |
| 215 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] |
| 216 | #[serde(rename_all = "UPPERCASE")] |
| 217 | pub enum Method { |
| 218 | Get, |
| 219 | Post, |
| 220 | Put, |
| 221 | Patch, |
| 222 | Delete, |
| 223 | Head, |
| 224 | Options, |
| 225 | } |
| 226 | |
| 227 | impl Method { |
| 228 | pub fn parse(s: &str) -> Option<Self> { |
| 229 | Some(match s.to_ascii_lowercase().as_str() { |
| 230 | "get" => Self::Get, |
| 231 | "post" => Self::Post, |
| 232 | "put" => Self::Put, |
| 233 | "patch" => Self::Patch, |
| 234 | "delete" => Self::Delete, |
| 235 | "head" => Self::Head, |
| 236 | "options" => Self::Options, |
| 237 | _ => return None, |
| 238 | }) |
| 239 | } |
| 240 | } |
| 241 | |
| 242 | impl fmt::Display for Method { |
| 243 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { |
| 244 | let s = match self { |
| 245 | Self::Get => "GET", |
| 246 | Self::Post => "POST", |
| 247 | Self::Put => "PUT", |
| 248 | Self::Patch => "PATCH", |
| 249 | Self::Delete => "DELETE", |
| 250 | Self::Head => "HEAD", |
| 251 | Self::Options => "OPTIONS", |
| 252 | }; |
| 253 | f.write_str(s) |
| 254 | } |
| 255 | } |
| 256 | |
| 257 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] |
| 258 | pub struct KeyValueRow { |
| 259 | pub key: String, |
| 260 | #[serde(default)] |
| 261 | pub value: String, |
| 262 | #[serde(default = "default_enabled")] |
| 263 | pub enabled: bool, |
| 264 | } |
| 265 | |
| 266 | fn default_enabled() -> bool { |
| 267 | true |
| 268 | } |
| 269 | |
| 270 | impl KeyValueRow { |
| 271 | pub fn new(key: impl Into<String>, value: impl Into<String>, enabled: bool) -> Self { |
| 272 | Self { |
| 273 | key: key.into(), |
| 274 | value: value.into(), |
| 275 | enabled, |
| 276 | } |
| 277 | } |
| 278 | } |
| 279 | |
| 280 | /// Spec-derived documentation for one input to a request: a parameter, or a |
| 281 | /// field of the request body. Stored on the request (rather than looked up in |
| 282 | /// the spec on demand) so the Docs tab works for collections whose spec is a |
| 283 | /// URL that may be gone, moved or behind auth by the time you open them. |
| 284 | #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] |
| 285 | pub struct FieldDoc { |
| 286 | /// Parameter name, or dotted path for a body field (`owner.address[].zip`). |
| 287 | pub name: String, |
| 288 | /// `path`, `query`, `header` or `body`. |
| 289 | pub location: String, |
| 290 | /// Rendered type, e.g. `string(uuid)`, `integer`, `array<string>`. |
| 291 | #[serde(default)] |
| 292 | pub ty: String, |
| 293 | #[serde(default)] |
| 294 | pub required: bool, |
| 295 | /// `enum` values — the "options" this field accepts. |
| 296 | #[serde(default, skip_serializing_if = "Vec::is_empty")] |
| 297 | pub options: Vec<String>, |
| 298 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 299 | pub description: Option<String>, |
| 300 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 301 | pub default: Option<String>, |
| 302 | } |
| 303 | |
| 304 | /// How the sidebar labels a request. Spec-derived names (`operationId`) are |
| 305 | /// often long and unreadable, so the label is a view concern, independent of |
| 306 | /// the stored `name`. |
| 307 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] |
| 308 | #[serde(rename_all = "lowercase")] |
| 309 | pub enum LabelMode { |
| 310 | /// The request's `name` (renameable with `r`). |
| 311 | #[default] |
| 312 | Name, |
| 313 | /// The spec's `summary`, falling back to `name`. |
| 314 | Summary, |
| 315 | /// The path template, e.g. `/pets/{petId}`. |
| 316 | Path, |
| 317 | } |
| 318 | |
| 319 | impl LabelMode { |
| 320 | pub const ALL: [LabelMode; 3] = [LabelMode::Name, LabelMode::Summary, LabelMode::Path]; |
| 321 | |
| 322 | pub fn next(self) -> Self { |
| 323 | let i = Self::ALL.iter().position(|m| *m == self).unwrap_or(0); |
| 324 | Self::ALL[(i + 1) % Self::ALL.len()] |
| 325 | } |
| 326 | |
| 327 | pub fn title(self) -> &'static str { |
| 328 | match self { |
| 329 | LabelMode::Name => "name", |
| 330 | LabelMode::Summary => "summary", |
| 331 | LabelMode::Path => "path", |
| 332 | } |
| 333 | } |
| 334 | } |
| 335 | |
| 336 | #[derive(Debug, Clone, Serialize, Deserialize)] |
| 337 | pub struct SavedRequest { |
| 338 | pub id: Uuid, |
| 339 | pub name: String, |
| 340 | /// The spec's `summary` for this operation, kept so the sidebar can label |
| 341 | /// requests by it without destroying a user-chosen `name`. |
| 342 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 343 | pub summary: Option<String>, |
| 344 | /// The spec's `operationId`, kept for the same reason. |
| 345 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 346 | pub operation_id: Option<String>, |
| 347 | /// The spec's operation `description`, shown in the Docs tab. |
| 348 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 349 | pub description: Option<String>, |
| 350 | /// Types, enums and descriptions for params and body fields (Docs tab). |
| 351 | /// Empty for hand-made requests and for collections imported before this |
| 352 | /// existed — re-importing the spec fills it in. |
| 353 | #[serde(default, skip_serializing_if = "Vec::is_empty")] |
| 354 | pub docs: Vec<FieldDoc>, |
| 355 | pub method: Method, |
| 356 | /// Path template, may contain `{param}` placeholders and `{{variables}}`. |
| 357 | pub path: String, |
| 358 | #[serde(default)] |
| 359 | pub path_params: Vec<KeyValueRow>, |
| 360 | #[serde(default)] |
| 361 | pub query: Vec<KeyValueRow>, |
| 362 | #[serde(default)] |
| 363 | pub headers: Vec<KeyValueRow>, |
| 364 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 365 | pub body: Option<String>, |
| 366 | #[serde(default, skip_serializing_if = "Vec::is_empty")] |
| 367 | pub tags: Vec<String>, |
| 368 | } |
| 369 | |
| 370 | impl SavedRequest { |
| 371 | pub fn blank(name: impl Into<String>) -> Self { |
| 372 | Self { |
| 373 | id: Uuid::new_v4(), |
| 374 | name: name.into(), |
| 375 | summary: None, |
| 376 | operation_id: None, |
| 377 | description: None, |
| 378 | docs: Vec::new(), |
| 379 | method: Method::Get, |
| 380 | path: "/".into(), |
| 381 | path_params: Vec::new(), |
| 382 | query: Vec::new(), |
| 383 | headers: Vec::new(), |
| 384 | body: None, |
| 385 | tags: Vec::new(), |
| 386 | } |
| 387 | } |
| 388 | |
| 389 | /// Rewrite `path_params` to hold exactly one row per `{placeholder}` in |
| 390 | /// `path`, in path order. Surviving rows keep their value and `enabled` |
| 391 | /// flag; rows whose placeholder is gone are dropped, since |
| 392 | /// [`crate::http::client`]'s `build_url` ignores them anyway and a stale |
| 393 | /// row only makes the Params table lie. Returns whether anything changed. |
| 394 | pub fn sync_path_params(&mut self) -> bool { |
| 395 | let names = path_placeholders(&self.path); |
| 396 | let synced: Vec<KeyValueRow> = names |
| 397 | .iter() |
| 398 | .map(|name| { |
| 399 | self.path_params |
| 400 | .iter() |
| 401 | .find(|r| &r.key == name) |
| 402 | .cloned() |
| 403 | .unwrap_or_else(|| KeyValueRow::new(name.clone(), "", true)) |
| 404 | }) |
| 405 | .collect(); |
| 406 | let changed = synced != self.path_params; |
| 407 | if changed { |
| 408 | self.path_params = synced; |
| 409 | } |
| 410 | changed |
| 411 | } |
| 412 | |
| 413 | /// Sidebar label under the collection's current [`LabelMode`]. Every mode |
| 414 | /// falls back to something non-empty so rows are never blank. |
| 415 | pub fn label(&self, mode: LabelMode) -> &str { |
| 416 | let candidate = match mode { |
| 417 | LabelMode::Name => Some(self.name.as_str()), |
| 418 | LabelMode::Summary => self.summary.as_deref(), |
| 419 | LabelMode::Path => Some(self.path.as_str()), |
| 420 | }; |
| 421 | candidate |
| 422 | .filter(|s| !s.is_empty()) |
| 423 | .unwrap_or(if self.name.is_empty() { |
| 424 | self.path.as_str() |
| 425 | } else { |
| 426 | self.name.as_str() |
| 427 | }) |
| 428 | } |
| 429 | |
| 430 | /// Lowercased haystack for sidebar search: label fields plus method/tags. |
| 431 | pub fn matches(&self, needle: &str) -> bool { |
| 432 | let needle = needle.to_ascii_lowercase(); |
| 433 | let fields = [ |
| 434 | self.name.as_str(), |
| 435 | self.path.as_str(), |
| 436 | self.summary.as_deref().unwrap_or(""), |
| 437 | self.operation_id.as_deref().unwrap_or(""), |
| 438 | ]; |
| 439 | fields |
| 440 | .iter() |
| 441 | .any(|f| f.to_ascii_lowercase().contains(&needle)) |
| 442 | || self |
| 443 | .method |
| 444 | .to_string() |
| 445 | .to_ascii_lowercase() |
| 446 | .contains(&needle) |
| 447 | || self |
| 448 | .tags |
| 449 | .iter() |
| 450 | .any(|t| t.to_ascii_lowercase().contains(&needle)) |
| 451 | } |
| 452 | } |
| 453 | |
| 454 | #[cfg(test)] |
| 455 | mod tests { |
| 456 | use super::*; |
| 457 | |
| 458 | fn keys(rows: &[KeyValueRow]) -> Vec<&str> { |
| 459 | rows.iter().map(|r| r.key.as_str()).collect() |
| 460 | } |
| 461 | |
| 462 | #[test] |
| 463 | fn path_placeholders_finds_single_braces() { |
| 464 | assert_eq!( |
| 465 | path_placeholders("/pets/{petId}/photos/{photoId}"), |
| 466 | vec!["petId", "photoId"] |
| 467 | ); |
| 468 | assert!(path_placeholders("/pets").is_empty()); |
| 469 | // Deduped, and blank or path-spanning braces are ignored. |
| 470 | assert_eq!(path_placeholders("/a/{id}/b/{id}"), vec!["id"]); |
| 471 | assert!(path_placeholders("/a/{}/b").is_empty()); |
| 472 | assert!(path_placeholders("/a/{oops/b}").is_empty()); |
| 473 | // An unterminated brace ends the scan rather than looping. |
| 474 | assert!(path_placeholders("/pets/{petId").is_empty()); |
| 475 | } |
| 476 | |
| 477 | #[test] |
| 478 | fn path_placeholders_skips_double_brace_variables() { |
| 479 | assert_eq!(path_placeholders("/{{tenant}}/pets/{petId}"), vec!["petId"]); |
| 480 | assert!(path_placeholders("{{baseUrl}}/pets").is_empty()); |
| 481 | assert!(path_placeholders("/a/{{unterminated").is_empty()); |
| 482 | } |
| 483 | |
| 484 | #[test] |
| 485 | fn sync_path_params_adds_prunes_and_reorders() { |
| 486 | let mut req = SavedRequest::blank("r"); |
| 487 | req.path = "/orgs/{orgId}/pets/{petId}".into(); |
| 488 | req.path_params = vec![ |
| 489 | KeyValueRow::new("petId", "42", true), |
| 490 | KeyValueRow::new("stale", "x", true), |
| 491 | ]; |
| 492 | |
| 493 | assert!(req.sync_path_params()); |
| 494 | assert_eq!(keys(&req.path_params), vec!["orgId", "petId"]); |
| 495 | // A second call is a no-op. |
| 496 | assert!(!req.sync_path_params()); |
| 497 | |
| 498 | req.path = "/pets".into(); |
| 499 | assert!(req.sync_path_params()); |
| 500 | assert!(req.path_params.is_empty()); |
| 501 | } |
| 502 | |
| 503 | #[test] |
| 504 | fn sync_path_params_keeps_existing_values_and_flags() { |
| 505 | let mut req = SavedRequest::blank("r"); |
| 506 | req.path = "/pets/{petId}".into(); |
| 507 | req.path_params = vec![KeyValueRow::new("petId", "42", false)]; |
| 508 | |
| 509 | assert!(!req.sync_path_params()); |
| 510 | assert_eq!(req.path_params[0].value, "42"); |
| 511 | assert!(!req.path_params[0].enabled); |
| 512 | } |
| 513 | } |