aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates/shirabe-php-shim/src/url.rs
blob: 45920402200eb3e84f3634643646bf5fada5f04e (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
use crate::PhpMixed;
use indexmap::IndexMap;

#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct UrlComponents {
    pub scheme: Option<String>,
    pub host: Option<String>,
    pub port: Option<i64>,
    pub user: Option<String>,
    pub pass: Option<String>,
    pub path: Option<String>,
    pub query: Option<String>,
    pub fragment: Option<String>,
}

/// PHP `parse_url()`. `None` for an invalid URL.
pub fn parse_url(url: &str) -> Option<UrlComponents> {
    // TODO(php-semantics): PHP's parse_url uses php_url_parse_ex, which accepts relative
    // and partial URLs and leaves an absent component absent. reqwest::Url
    // (WHATWG/RFC 3986) requires an absolute URL, lowercases the host of special
    // schemes, and normalizes the path (e.g. "http://host" yields path "/"). This
    // is therefore not a byte-for-byte compatible port of parse_url.
    let parsed = reqwest::Url::parse(url).ok()?;
    Some(UrlComponents {
        scheme: Some(parsed.scheme().to_string()),
        host: parsed.host_str().map(str::to_string),
        port: parsed.port().map(i64::from),
        user: Some(parsed.username())
            .filter(|user| !user.is_empty())
            .map(str::to_string),
        pass: parsed.password().map(str::to_string),
        path: Some(parsed.path())
            .filter(|path| !path.is_empty())
            .map(str::to_string),
        query: parsed.query().map(str::to_string),
        fragment: parsed.fragment().map(str::to_string),
    })
}

pub fn http_build_query_mixed(
    data: &IndexMap<String, PhpMixed>,
    numeric_prefix: &str,
    arg_separator: &str,
) -> String {
    let mut pairs: Vec<(String, String)> = Vec::new();
    for (key, value) in data {
        // numeric_prefix is prepended only to integer keys at the top level.
        let key = if key.parse::<i64>().is_ok() {
            format!("{numeric_prefix}{key}")
        } else {
            key.clone()
        };
        append_query_pairs(&mut pairs, key, value);
    }
    encode_pairs(&pairs, arg_separator)
}

pub fn http_build_query(data: &[(&str, &str)]) -> String {
    encode_pairs(data, "&")
}

fn encode_pairs<T: serde::Serialize>(pairs: T, arg_separator: &str) -> String {
    let encoded = serde_urlencoded::to_string(pairs).unwrap();
    if arg_separator == "&" {
        encoded
    } else {
        // serde_urlencoded percent-encodes any literal '&' in keys/values, so the
        // only remaining '&' are the separators we are replacing.
        encoded.replace('&', arg_separator)
    }
}

fn append_query_pairs(pairs: &mut Vec<(String, String)>, key: String, value: &PhpMixed) {
    match value {
        // Null values are omitted from the query string entirely.
        PhpMixed::Null => {}
        PhpMixed::Bool(b) => pairs.push((key, if *b { "1" } else { "0" }.to_string())),
        PhpMixed::Int(i) => pairs.push((key, i.to_string())),
        PhpMixed::Float(f) => pairs.push((key, f.to_string())),
        PhpMixed::String(s) => pairs.push((key, s.clone())),
        PhpMixed::List(items) => {
            for (i, item) in items.iter().enumerate() {
                append_query_pairs(pairs, format!("{key}[{i}]"), item);
            }
        }
        PhpMixed::Array(map) | PhpMixed::Object(map) => {
            for (k, v) in map {
                append_query_pairs(pairs, format!("{key}[{k}]"), v);
            }
        }
    }
}