| 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 | |
| 111 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] |
| 112 | #[serde(rename_all = "lowercase")] |
| 113 | pub enum AuthStyle { |
| 114 | /// client_id/client_secret sent via HTTP Basic header (RFC 6749 default). |
| 115 | #[default] |
| 116 | Basic, |
| 117 | /// client_id/client_secret sent in the form body. |
| 118 | Post, |
| 119 | } |
| 120 | |
| 121 | #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] |
| 122 | pub struct OAuthConfig { |
| 123 | #[serde(default)] |
| 124 | pub token_url: String, |
| 125 | #[serde(default)] |
| 126 | pub client_id: String, |
| 127 | #[serde(default)] |
| 128 | pub client_secret: String, |
| 129 | #[serde(default)] |
| 130 | pub scopes: Vec<String>, |
| 131 | #[serde(default)] |
| 132 | pub auth_style: AuthStyle, |
| 133 | } |
| 134 | |
| 135 | impl OAuthConfig { |
| 136 | pub fn is_configured(&self) -> bool { |
| 137 | !self.token_url.is_empty() && !self.client_id.is_empty() |
| 138 | } |
| 139 | } |
| 140 | |
| 141 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] |
| 142 | #[serde(rename_all = "UPPERCASE")] |
| 143 | pub enum Method { |
| 144 | Get, |
| 145 | Post, |
| 146 | Put, |
| 147 | Patch, |
| 148 | Delete, |
| 149 | Head, |
| 150 | Options, |
| 151 | } |
| 152 | |
| 153 | impl Method { |
| 154 | pub fn parse(s: &str) -> Option<Self> { |
| 155 | Some(match s.to_ascii_lowercase().as_str() { |
| 156 | "get" => Self::Get, |
| 157 | "post" => Self::Post, |
| 158 | "put" => Self::Put, |
| 159 | "patch" => Self::Patch, |
| 160 | "delete" => Self::Delete, |
| 161 | "head" => Self::Head, |
| 162 | "options" => Self::Options, |
| 163 | _ => return None, |
| 164 | }) |
| 165 | } |
| 166 | } |
| 167 | |
| 168 | impl fmt::Display for Method { |
| 169 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { |
| 170 | let s = match self { |
| 171 | Self::Get => "GET", |
| 172 | Self::Post => "POST", |
| 173 | Self::Put => "PUT", |
| 174 | Self::Patch => "PATCH", |
| 175 | Self::Delete => "DELETE", |
| 176 | Self::Head => "HEAD", |
| 177 | Self::Options => "OPTIONS", |
| 178 | }; |
| 179 | f.write_str(s) |
| 180 | } |
| 181 | } |
| 182 | |
| 183 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] |
| 184 | pub struct KeyValueRow { |
| 185 | pub key: String, |
| 186 | #[serde(default)] |
| 187 | pub value: String, |
| 188 | #[serde(default = "default_enabled")] |
| 189 | pub enabled: bool, |
| 190 | } |
| 191 | |
| 192 | fn default_enabled() -> bool { |
| 193 | true |
| 194 | } |
| 195 | |
| 196 | impl KeyValueRow { |
| 197 | pub fn new(key: impl Into<String>, value: impl Into<String>, enabled: bool) -> Self { |
| 198 | Self { |
| 199 | key: key.into(), |
| 200 | value: value.into(), |
| 201 | enabled, |
| 202 | } |
| 203 | } |
| 204 | } |
| 205 | |
| 206 | /// Spec-derived documentation for one input to a request: a parameter, or a |
| 207 | /// field of the request body. Stored on the request (rather than looked up in |
| 208 | /// the spec on demand) so the Docs tab works for collections whose spec is a |
| 209 | /// URL that may be gone, moved or behind auth by the time you open them. |
| 210 | #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] |
| 211 | pub struct FieldDoc { |
| 212 | /// Parameter name, or dotted path for a body field (`owner.address[].zip`). |
| 213 | pub name: String, |
| 214 | /// `path`, `query`, `header` or `body`. |
| 215 | pub location: String, |
| 216 | /// Rendered type, e.g. `string(uuid)`, `integer`, `array<string>`. |
| 217 | #[serde(default)] |
| 218 | pub ty: String, |
| 219 | #[serde(default)] |
| 220 | pub required: bool, |
| 221 | /// `enum` values — the "options" this field accepts. |
| 222 | #[serde(default, skip_serializing_if = "Vec::is_empty")] |
| 223 | pub options: Vec<String>, |
| 224 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 225 | pub description: Option<String>, |
| 226 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 227 | pub default: Option<String>, |
| 228 | } |
| 229 | |
| 230 | /// How the sidebar labels a request. Spec-derived names (`operationId`) are |
| 231 | /// often long and unreadable, so the label is a view concern, independent of |
| 232 | /// the stored `name`. |
| 233 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] |
| 234 | #[serde(rename_all = "lowercase")] |
| 235 | pub enum LabelMode { |
| 236 | /// The request's `name` (renameable with `r`). |
| 237 | #[default] |
| 238 | Name, |
| 239 | /// The spec's `summary`, falling back to `name`. |
| 240 | Summary, |
| 241 | /// The path template, e.g. `/pets/{petId}`. |
| 242 | Path, |
| 243 | } |
| 244 | |
| 245 | impl LabelMode { |
| 246 | pub const ALL: [LabelMode; 3] = [LabelMode::Name, LabelMode::Summary, LabelMode::Path]; |
| 247 | |
| 248 | pub fn next(self) -> Self { |
| 249 | let i = Self::ALL.iter().position(|m| *m == self).unwrap_or(0); |
| 250 | Self::ALL[(i + 1) % Self::ALL.len()] |
| 251 | } |
| 252 | |
| 253 | pub fn title(self) -> &'static str { |
| 254 | match self { |
| 255 | LabelMode::Name => "name", |
| 256 | LabelMode::Summary => "summary", |
| 257 | LabelMode::Path => "path", |
| 258 | } |
| 259 | } |
| 260 | } |
| 261 | |
| 262 | #[derive(Debug, Clone, Serialize, Deserialize)] |
| 263 | pub struct SavedRequest { |
| 264 | pub id: Uuid, |
| 265 | pub name: String, |
| 266 | /// The spec's `summary` for this operation, kept so the sidebar can label |
| 267 | /// requests by it without destroying a user-chosen `name`. |
| 268 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 269 | pub summary: Option<String>, |
| 270 | /// The spec's `operationId`, kept for the same reason. |
| 271 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 272 | pub operation_id: Option<String>, |
| 273 | /// The spec's operation `description`, shown in the Docs tab. |
| 274 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 275 | pub description: Option<String>, |
| 276 | /// Types, enums and descriptions for params and body fields (Docs tab). |
| 277 | /// Empty for hand-made requests and for collections imported before this |
| 278 | /// existed — re-importing the spec fills it in. |
| 279 | #[serde(default, skip_serializing_if = "Vec::is_empty")] |
| 280 | pub docs: Vec<FieldDoc>, |
| 281 | pub method: Method, |
| 282 | /// Path template, may contain `{param}` placeholders and `{{variables}}`. |
| 283 | pub path: String, |
| 284 | #[serde(default)] |
| 285 | pub path_params: Vec<KeyValueRow>, |
| 286 | #[serde(default)] |
| 287 | pub query: Vec<KeyValueRow>, |
| 288 | #[serde(default)] |
| 289 | pub headers: Vec<KeyValueRow>, |
| 290 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 291 | pub body: Option<String>, |
| 292 | #[serde(default, skip_serializing_if = "Vec::is_empty")] |
| 293 | pub tags: Vec<String>, |
| 294 | } |
| 295 | |
| 296 | impl SavedRequest { |
| 297 | pub fn blank(name: impl Into<String>) -> Self { |
| 298 | Self { |
| 299 | id: Uuid::new_v4(), |
| 300 | name: name.into(), |
| 301 | summary: None, |
| 302 | operation_id: None, |
| 303 | description: None, |
| 304 | docs: Vec::new(), |
| 305 | method: Method::Get, |
| 306 | path: "/".into(), |
| 307 | path_params: Vec::new(), |
| 308 | query: Vec::new(), |
| 309 | headers: Vec::new(), |
| 310 | body: None, |
| 311 | tags: Vec::new(), |
| 312 | } |
| 313 | } |
| 314 | |
| 315 | /// Rewrite `path_params` to hold exactly one row per `{placeholder}` in |
| 316 | /// `path`, in path order. Surviving rows keep their value and `enabled` |
| 317 | /// flag; rows whose placeholder is gone are dropped, since |
| 318 | /// [`crate::http::client`]'s `build_url` ignores them anyway and a stale |
| 319 | /// row only makes the Params table lie. Returns whether anything changed. |
| 320 | pub fn sync_path_params(&mut self) -> bool { |
| 321 | let names = path_placeholders(&self.path); |
| 322 | let synced: Vec<KeyValueRow> = names |
| 323 | .iter() |
| 324 | .map(|name| { |
| 325 | self.path_params |
| 326 | .iter() |
| 327 | .find(|r| &r.key == name) |
| 328 | .cloned() |
| 329 | .unwrap_or_else(|| KeyValueRow::new(name.clone(), "", true)) |
| 330 | }) |
| 331 | .collect(); |
| 332 | let changed = synced != self.path_params; |
| 333 | if changed { |
| 334 | self.path_params = synced; |
| 335 | } |
| 336 | changed |
| 337 | } |
| 338 | |
| 339 | /// Sidebar label under the collection's current [`LabelMode`]. Every mode |
| 340 | /// falls back to something non-empty so rows are never blank. |
| 341 | pub fn label(&self, mode: LabelMode) -> &str { |
| 342 | let candidate = match mode { |
| 343 | LabelMode::Name => Some(self.name.as_str()), |
| 344 | LabelMode::Summary => self.summary.as_deref(), |
| 345 | LabelMode::Path => Some(self.path.as_str()), |
| 346 | }; |
| 347 | candidate |
| 348 | .filter(|s| !s.is_empty()) |
| 349 | .unwrap_or(if self.name.is_empty() { |
| 350 | self.path.as_str() |
| 351 | } else { |
| 352 | self.name.as_str() |
| 353 | }) |
| 354 | } |
| 355 | |
| 356 | /// Lowercased haystack for sidebar search: label fields plus method/tags. |
| 357 | pub fn matches(&self, needle: &str) -> bool { |
| 358 | let needle = needle.to_ascii_lowercase(); |
| 359 | let fields = [ |
| 360 | self.name.as_str(), |
| 361 | self.path.as_str(), |
| 362 | self.summary.as_deref().unwrap_or(""), |
| 363 | self.operation_id.as_deref().unwrap_or(""), |
| 364 | ]; |
| 365 | fields |
| 366 | .iter() |
| 367 | .any(|f| f.to_ascii_lowercase().contains(&needle)) |
| 368 | || self |
| 369 | .method |
| 370 | .to_string() |
| 371 | .to_ascii_lowercase() |
| 372 | .contains(&needle) |
| 373 | || self |
| 374 | .tags |
| 375 | .iter() |
| 376 | .any(|t| t.to_ascii_lowercase().contains(&needle)) |
| 377 | } |
| 378 | } |
| 379 | |
| 380 | #[cfg(test)] |
| 381 | mod tests { |
| 382 | use super::*; |
| 383 | |
| 384 | fn keys(rows: &[KeyValueRow]) -> Vec<&str> { |
| 385 | rows.iter().map(|r| r.key.as_str()).collect() |
| 386 | } |
| 387 | |
| 388 | #[test] |
| 389 | fn path_placeholders_finds_single_braces() { |
| 390 | assert_eq!( |
| 391 | path_placeholders("/pets/{petId}/photos/{photoId}"), |
| 392 | vec!["petId", "photoId"] |
| 393 | ); |
| 394 | assert!(path_placeholders("/pets").is_empty()); |
| 395 | // Deduped, and blank or path-spanning braces are ignored. |
| 396 | assert_eq!(path_placeholders("/a/{id}/b/{id}"), vec!["id"]); |
| 397 | assert!(path_placeholders("/a/{}/b").is_empty()); |
| 398 | assert!(path_placeholders("/a/{oops/b}").is_empty()); |
| 399 | // An unterminated brace ends the scan rather than looping. |
| 400 | assert!(path_placeholders("/pets/{petId").is_empty()); |
| 401 | } |
| 402 | |
| 403 | #[test] |
| 404 | fn path_placeholders_skips_double_brace_variables() { |
| 405 | assert_eq!(path_placeholders("/{{tenant}}/pets/{petId}"), vec!["petId"]); |
| 406 | assert!(path_placeholders("{{baseUrl}}/pets").is_empty()); |
| 407 | assert!(path_placeholders("/a/{{unterminated").is_empty()); |
| 408 | } |
| 409 | |
| 410 | #[test] |
| 411 | fn sync_path_params_adds_prunes_and_reorders() { |
| 412 | let mut req = SavedRequest::blank("r"); |
| 413 | req.path = "/orgs/{orgId}/pets/{petId}".into(); |
| 414 | req.path_params = vec![ |
| 415 | KeyValueRow::new("petId", "42", true), |
| 416 | KeyValueRow::new("stale", "x", true), |
| 417 | ]; |
| 418 | |
| 419 | assert!(req.sync_path_params()); |
| 420 | assert_eq!(keys(&req.path_params), vec!["orgId", "petId"]); |
| 421 | // A second call is a no-op. |
| 422 | assert!(!req.sync_path_params()); |
| 423 | |
| 424 | req.path = "/pets".into(); |
| 425 | assert!(req.sync_path_params()); |
| 426 | assert!(req.path_params.is_empty()); |
| 427 | } |
| 428 | |
| 429 | #[test] |
| 430 | fn sync_path_params_keeps_existing_values_and_flags() { |
| 431 | let mut req = SavedRequest::blank("r"); |
| 432 | req.path = "/pets/{petId}".into(); |
| 433 | req.path_params = vec![KeyValueRow::new("petId", "42", false)]; |
| 434 | |
| 435 | assert!(!req.sync_path_params()); |
| 436 | assert_eq!(req.path_params[0].value, "42"); |
| 437 | assert!(!req.path_params[0].enabled); |
| 438 | } |
| 439 | } |