src/http/url_input.rs 6.9 K raw
1
//! Decompose a URL typed or pasted into the URL bar. This is the inverse of
2
//! [`super::client`]'s `build_url`: that composes `base + path + query` into a
3
//! request, this splits a pasted URL back into the pieces a
4
//! [`crate::model::SavedRequest`] stores.
5
6
use url::Url;
7
8
use crate::model::KeyValueRow;
9
10
/// The pieces a URL-bar entry decomposes into.
11
#[derive(Debug, Clone, Default, PartialEq, Eq)]
12
pub struct UrlParts {
13
    /// Origin (`scheme://host[:port]`) when an absolute http(s) URL was pasted;
14
    /// `None` for a bare path, which is what the field normally holds.
15
    pub origin: Option<String>,
16
    /// Always leading-slash (or a `{{var}}` template), relative to `origin`.
17
    pub path: String,
18
    /// `None` when the input had no `?` at all, meaning "leave the existing
19
    /// query rows alone"; `Some(rows)` when it did, even if empty — a bare `?`
20
    /// clears them.
21
    pub query: Option<Vec<KeyValueRow>>,
22
}
23
24
pub fn split_url_input(input: &str) -> UrlParts {
25
    let input = input.trim();
26
    if input.is_empty() {
27
        return UrlParts {
28
            origin: None,
29
            path: "/".into(),
30
            query: None,
31
        };
32
    }
33
34
    // `Url::parse` succeeds for anything with a scheme, including nonsense like
35
    // `localhost:8080/pets` (scheme `localhost`, path `8080/pets`), so the
36
    // scheme and host checks are load-bearing rather than cosmetic.
37
    if let Ok(u) = Url::parse(input)
38
        && matches!(u.scheme(), "http" | "https")
39
        && u.host().is_some()
40
    {
41
        return UrlParts {
42
            origin: Some(u.origin().ascii_serialization()),
43
            path: restore_braces(u.path()),
44
            query: u.query().map(parse_query),
45
            // The fragment is deliberately dropped: it is never sent to a server.
46
        };
47
    }
48
49
    let no_fragment = input.split_once('#').map_or(input, |(head, _)| head);
50
    let (path, query) = match no_fragment.split_once('?') {
51
        Some((p, q)) => (p, Some(parse_query(q))),
52
        None => (no_fragment, None),
53
    };
54
    // `{{baseUrl}}/pets` is a template, not a relative path — leave it verbatim.
55
    let path = if path.is_empty() {
56
        "/".to_string()
57
    } else if path.starts_with('/') || path.starts_with("{{") {
58
        path.to_string()
59
    } else {
60
        format!("/{path}")
61
    };
62
    UrlParts {
63
        origin: None,
64
        path,
65
        query,
66
    }
67
}
68
69
/// Undo the `url` crate's percent-encoding of `{` and `}`, which are in its
70
/// path encode set — so `/pets/{id}` comes back as `/pets/%7Bid%7D` and would
71
/// break both `{pathParam}` replacement and `{{variable}}` substitution. Only
72
/// the braces are restored; a blanket decode would corrupt segments the user
73
/// percent-encoded on purpose.
74
fn restore_braces(s: &str) -> String {
75
    s.replace("%7B", "{")
76
        .replace("%7b", "{")
77
        .replace("%7D", "}")
78
        .replace("%7d", "}")
79
}
80
81
/// Query string to enabled rows. Values are decoded here and re-encoded by
82
/// `build_url`'s `.query(&pairs)`, so they round-trip.
83
fn parse_query(raw: &str) -> Vec<KeyValueRow> {
84
    url::form_urlencoded::parse(raw.as_bytes())
85
        .map(|(k, v)| KeyValueRow::new(k.as_ref(), v.as_ref(), true))
86
        .filter(|r| !r.key.is_empty())
87
        .collect()
88
}
89
90
#[cfg(test)]
91
mod tests {
92
    use super::*;
93
94
    fn rows(parts: &UrlParts) -> Vec<(String, String)> {
95
        parts
96
            .query
97
            .as_ref()
98
            .map(|q| q.iter().map(|r| (r.key.clone(), r.value.clone())).collect())
99
            .unwrap_or_default()
100
    }
101
102
    #[test]
103
    fn splits_a_full_url() {
104
        let p = split_url_input("https://api.example.com/v1/pets?limit=10");
105
        assert_eq!(p.origin.as_deref(), Some("https://api.example.com"));
106
        assert_eq!(p.path, "/v1/pets");
107
        assert_eq!(rows(&p), vec![("limit".to_string(), "10".to_string())]);
108
    }
109
110
    #[test]
111
    fn bare_path_has_no_origin() {
112
        let p = split_url_input("/v1/pets");
113
        assert_eq!(p.origin, None);
114
        assert_eq!(p.path, "/v1/pets");
115
        assert_eq!(p.query, None);
116
    }
117
118
    #[test]
119
    fn relative_path_gains_a_leading_slash() {
120
        assert_eq!(split_url_input("pets/42").path, "/pets/42");
121
    }
122
123
    #[test]
124
    fn host_with_port_and_no_scheme_is_relative() {
125
        // `Url::parse` accepts this with scheme "localhost"; we must not.
126
        let p = split_url_input("localhost:8080/pets");
127
        assert_eq!(p.origin, None);
128
        assert_eq!(p.path, "/localhost:8080/pets");
129
    }
130
131
    #[test]
132
    fn keeps_brace_placeholders_unencoded() {
133
        let p = split_url_input("https://api.example.com/pets/{petId}/photos");
134
        assert_eq!(p.path, "/pets/{petId}/photos");
135
    }
136
137
    #[test]
138
    fn keeps_double_brace_variables_in_a_relative_path() {
139
        let p = split_url_input("{{prefix}}/pets");
140
        assert_eq!(p.origin, None);
141
        assert_eq!(p.path, "{{prefix}}/pets");
142
    }
143
144
    #[test]
145
    fn drops_the_fragment() {
146
        assert_eq!(
147
            split_url_input("https://api.example.com/pets#section").path,
148
            "/pets"
149
        );
150
        assert_eq!(split_url_input("/pets#section").path, "/pets");
151
        assert_eq!(split_url_input("/pets?a=1#section").path, "/pets");
152
        assert_eq!(
153
            rows(&split_url_input("/pets?a=1#section")),
154
            vec![("a".to_string(), "1".to_string())]
155
        );
156
    }
157
158
    #[test]
159
    fn empty_input_becomes_root_path() {
160
        let p = split_url_input("   ");
161
        assert_eq!(p.path, "/");
162
        assert_eq!(p.origin, None);
163
        assert_eq!(p.query, None);
164
    }
165
166
    #[test]
167
    fn absent_question_mark_leaves_query_none() {
168
        assert_eq!(split_url_input("https://api.example.com/pets").query, None);
169
        assert_eq!(split_url_input("/pets").query, None);
170
    }
171
172
    #[test]
173
    fn bare_question_mark_clears_the_query() {
174
        assert_eq!(split_url_input("/pets?").query, Some(Vec::new()));
175
        assert_eq!(
176
            split_url_input("https://api.example.com/pets?").query,
177
            Some(Vec::new())
178
        );
179
    }
180
181
    #[test]
182
    fn decodes_query_values() {
183
        let p = split_url_input("/search?q=hello%20world&tag=a%2Bb");
184
        assert_eq!(
185
            rows(&p),
186
            vec![
187
                ("q".to_string(), "hello world".to_string()),
188
                ("tag".to_string(), "a+b".to_string()),
189
            ]
190
        );
191
    }
192
193
    #[test]
194
    fn default_ports_are_dropped_from_the_origin() {
195
        assert_eq!(
196
            split_url_input("https://api.example.com:443/pets")
197
                .origin
198
                .as_deref(),
199
            Some("https://api.example.com")
200
        );
201
        assert_eq!(
202
            split_url_input("http://localhost:8080/pets")
203
                .origin
204
                .as_deref(),
205
            Some("http://localhost:8080")
206
        );
207
    }
208
209
    #[test]
210
    fn query_rows_are_enabled_and_skip_empty_keys() {
211
        let p = split_url_input("/pets?a=1&=2&b");
212
        let q = p.query.unwrap();
213
        assert_eq!(q.len(), 2);
214
        assert!(q.iter().all(|r| r.enabled));
215
        assert_eq!(q[1].key, "b");
216
        assert_eq!(q[1].value, "");
217
    }
218
}