src/model.rs 14.2 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
#[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
}