src/model.rs 17.1 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
    /// 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
}