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