Skip to main content

rust_tg_bot_raw/request/
request_data.rs

1//! Collects all parameters and files needed for one request to the Telegram Bot API.
2//!
3//! This mirrors `telegram.request.RequestData` from python-telegram-bot.
4
5use std::collections::HashMap;
6
7use serde_json::Value;
8
9use super::request_parameter::RequestParameter;
10
11/// Aggregates all [`RequestParameter`]s for a single Bot API call.
12///
13/// # Relationship to Python source
14///
15/// | Python attribute / property | Rust equivalent |
16/// |---|---|
17/// | `contains_files` | [`RequestData::contains_files()`] |
18/// | `parameters` | [`RequestData::parameters()`] |
19/// | `json_parameters` | [`RequestData::json_parameters()`] |
20/// | `json_payload` | [`RequestData::json_payload()`] |
21/// | `multipart_data` | [`RequestData::multipart_data()`] |
22/// | `url_encoded_parameters` | [`RequestData::url_encoded_parameters()`] |
23/// | `parametrized_url` | [`RequestData::parametrized_url()`] |
24#[derive(Debug, Clone, Default)]
25pub struct RequestData {
26    parameters: Vec<RequestParameter>,
27}
28
29impl RequestData {
30    /// Create an empty [`RequestData`].
31    pub fn new() -> Self {
32        Self::default()
33    }
34
35    /// Create a [`RequestData`] from an existing list of parameters.
36    ///
37    /// ```
38    /// use rust_tg_bot_raw::request::request_data::RequestData;
39    /// use rust_tg_bot_raw::request::request_parameter::RequestParameter;
40    /// use serde_json::json;
41    ///
42    /// let params = vec![RequestParameter::new("chat_id", json!(42))];
43    /// let data = RequestData::from_parameters(params);
44    /// assert!(!data.contains_files());
45    /// ```
46    pub fn from_parameters(parameters: Vec<RequestParameter>) -> Self {
47        Self { parameters }
48    }
49
50    /// Append a single parameter.
51    pub fn push(&mut self, param: RequestParameter) {
52        self.parameters.push(param);
53    }
54
55    /// Iterate over all parameters.
56    pub fn iter(&self) -> impl Iterator<Item = &RequestParameter> {
57        self.parameters.iter()
58    }
59
60    /// Returns `true` when at least one parameter carries attached files.
61    ///
62    /// Mirrors `RequestData.contains_files` in Python.
63    ///
64    /// ```
65    /// use rust_tg_bot_raw::request::request_data::RequestData;
66    /// use rust_tg_bot_raw::request::request_parameter::{InputFileRef, RequestParameter};
67    ///
68    /// let file = InputFileRef::direct(vec![0u8]);
69    /// let p = RequestParameter::file_only("photo", file);
70    /// let data = RequestData::from_parameters(vec![p]);
71    /// assert!(data.contains_files());
72    /// ```
73    pub fn contains_files(&self) -> bool {
74        self.parameters.iter().any(|p| p.input_files.is_some())
75    }
76
77    /// All parameters as a `HashMap<name, Value>`, excluding those whose value
78    /// is `None`.
79    ///
80    /// Mirrors `RequestData.parameters` in Python.
81    pub fn parameters(&self) -> HashMap<&str, &Value> {
82        self.parameters
83            .iter()
84            .filter_map(|p| p.value.as_ref().map(|v| (p.name.as_ref(), v)))
85            .collect()
86    }
87
88    /// All parameters as `HashMap<name, json_encoded_string>`, excluding those
89    /// whose JSON value is `None`.
90    ///
91    /// Mirrors `RequestData.json_parameters` in Python.
92    ///
93    /// ```
94    /// use rust_tg_bot_raw::request::request_data::RequestData;
95    /// use rust_tg_bot_raw::request::request_parameter::RequestParameter;
96    /// use serde_json::json;
97    ///
98    /// let data = RequestData::from_parameters(vec![
99    ///     RequestParameter::new("chat_id", json!(99)),
100    ///     RequestParameter::new("text", json!("hello")),
101    /// ]);
102    /// let jp = data.json_parameters();
103    /// assert_eq!(jp.get("chat_id").map(String::as_str), Some("99"));
104    /// assert_eq!(jp.get("text").map(String::as_str), Some("hello"));
105    /// ```
106    pub fn json_parameters(&self) -> HashMap<String, String> {
107        self.parameters
108            .iter()
109            .filter_map(|p| p.json_value().map(|v| (p.name.as_ref().to_owned(), v)))
110            .collect()
111    }
112
113    /// Serialize the JSON parameters to a UTF-8 byte payload.
114    ///
115    /// Mirrors `RequestData.json_payload` in Python.
116    ///
117    /// ```
118    /// use rust_tg_bot_raw::request::request_data::RequestData;
119    /// use rust_tg_bot_raw::request::request_parameter::RequestParameter;
120    /// use serde_json::json;
121    ///
122    /// let data = RequestData::from_parameters(vec![
123    ///     RequestParameter::new("chat_id", json!(1)),
124    /// ]);
125    /// let payload = data.json_payload();
126    /// let parsed: serde_json::Value = serde_json::from_slice(&payload).unwrap();
127    /// assert_eq!(parsed["chat_id"], json!("1"));
128    /// ```
129    pub fn json_payload(&self) -> Vec<u8> {
130        let map = self.json_parameters();
131        // serde_json serialises HashMap<String, String> as a JSON object.
132        serde_json::to_vec(&map).expect("HashMap<String,String> is always serialisable")
133    }
134
135    /// URL-encode all JSON parameters.
136    ///
137    /// Mirrors `RequestData.url_encoded_parameters` in Python (without the
138    /// `encode_kwargs` variant — use [`Self::url_encoded_parameters_with`] for
139    /// that).
140    ///
141    /// ```
142    /// use rust_tg_bot_raw::request::request_data::RequestData;
143    /// use rust_tg_bot_raw::request::request_parameter::RequestParameter;
144    /// use serde_json::json;
145    ///
146    /// let data = RequestData::from_parameters(vec![
147    ///     RequestParameter::new("a", json!("1")),
148    /// ]);
149    /// let encoded = data.url_encoded_parameters();
150    /// assert!(encoded.contains("a=1"));
151    /// ```
152    pub fn url_encoded_parameters(&self) -> String {
153        let map = self.json_parameters();
154        // Build the query string manually to avoid pulling in an extra crate.
155        // This matches `urllib.parse.urlencode` for the common case.
156        map.iter()
157            .map(|(k, v)| format!("{}={}", percent_encode(k), percent_encode(v)))
158            .collect::<Vec<_>>()
159            .join("&")
160    }
161
162    /// Attach the URL-encoded parameters to a base URL with a `?` separator.
163    ///
164    /// Mirrors `RequestData.parametrized_url` in Python.
165    ///
166    /// ```
167    /// use rust_tg_bot_raw::request::request_data::RequestData;
168    /// use rust_tg_bot_raw::request::request_parameter::RequestParameter;
169    /// use serde_json::json;
170    ///
171    /// let data = RequestData::from_parameters(vec![
172    ///     RequestParameter::new("offset", json!("0")),
173    /// ]);
174    /// let url = data.parametrized_url("https://api.telegram.org/bot<token>/getUpdates");
175    /// assert!(url.starts_with("https://api.telegram.org/bot<token>/getUpdates?"));
176    /// ```
177    pub fn parametrized_url(&self, url: &str) -> String {
178        format!("{}?{}", url, self.url_encoded_parameters())
179    }
180
181    /// Collect multipart form parts contributed by all parameters.
182    ///
183    /// Returns `None` when [`Self::contains_files`] is `false`.
184    ///
185    /// The outer `HashMap` maps multipart part names to `(bytes, mime_type,
186    /// file_name)` triples so that [`crate::request::reqwest_impl`] can
187    /// assemble a `reqwest::multipart::Form` without needing to reach back into
188    /// this module.
189    pub fn multipart_data(&self) -> Option<HashMap<String, MultipartPart>> {
190        if !self.contains_files() {
191            return None;
192        }
193
194        let mut out: HashMap<String, MultipartPart> = HashMap::new();
195
196        for param in &self.parameters {
197            if let Some(parts) = param.multipart_data() {
198                for (part_name, file_ref) in parts {
199                    out.insert(
200                        part_name,
201                        MultipartPart {
202                            bytes: file_ref.bytes.clone(),
203                            mime_type: file_ref.effective_mime().to_owned(),
204                            file_name: file_ref.file_name.clone(),
205                        },
206                    );
207                }
208            }
209        }
210
211        Some(out)
212    }
213}
214
215/// A single binary part to be included in a `multipart/form-data` upload.
216#[derive(Debug, Clone)]
217pub struct MultipartPart {
218    /// Raw file bytes.
219    pub bytes: Vec<u8>,
220    /// MIME type, e.g. `"image/jpeg"`.
221    pub mime_type: String,
222    /// Optional filename hint.
223    pub file_name: Option<String>,
224}
225
226// ---------------------------------------------------------------------------
227// Internal helpers
228// ---------------------------------------------------------------------------
229
230/// Percent-encode a string for use in a URL query string.
231///
232/// Encodes all characters outside the unreserved set (A–Z a–z 0–9 `-` `_` `.`
233/// `~`) as `%XX`.  Spaces are encoded as `%20` (not `+`), matching the
234/// behaviour of Python's `urllib.parse.quote` (which `urlencode` uses
235/// internally for both keys and values).
236fn percent_encode(input: &str) -> String {
237    let mut out = String::with_capacity(input.len());
238    for byte in input.bytes() {
239        match byte {
240            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
241                out.push(byte as char)
242            }
243            _ => {
244                use std::fmt::Write as _;
245                let _ = write!(out, "%{byte:02X}");
246            }
247        }
248    }
249    out
250}
251
252#[cfg(test)]
253mod tests {
254    use serde_json::json;
255
256    use crate::request::request_parameter::{InputFileRef, RequestParameter};
257
258    use super::*;
259
260    fn make_plain(name: &'static str, v: Value) -> RequestParameter {
261        RequestParameter::new(name, v)
262    }
263
264    // ------------------------------------------------------------------
265    // contains_files
266    // ------------------------------------------------------------------
267
268    #[test]
269    fn contains_files_false_for_plain_params() {
270        let data = RequestData::from_parameters(vec![
271            make_plain("chat_id", json!(1)),
272            make_plain("text", json!("hi")),
273        ]);
274        assert!(!data.contains_files());
275    }
276
277    #[test]
278    fn contains_files_true_when_file_present() {
279        let file = InputFileRef::direct(vec![0xAB]);
280        let p = RequestParameter::file_only("photo", file);
281        let data = RequestData::from_parameters(vec![p]);
282        assert!(data.contains_files());
283    }
284
285    // ------------------------------------------------------------------
286    // json_parameters
287    // ------------------------------------------------------------------
288
289    #[test]
290    fn json_parameters_excludes_none_values() {
291        let file = InputFileRef::direct(vec![0]);
292        let p = RequestParameter::file_only("photo", file);
293        let data = RequestData::from_parameters(vec![
294            make_plain("chat_id", json!(7)),
295            p, // value is None
296        ]);
297        let jp = data.json_parameters();
298        assert!(jp.contains_key("chat_id"));
299        assert!(!jp.contains_key("photo"));
300    }
301
302    #[test]
303    fn json_parameters_string_not_double_encoded() {
304        let data = RequestData::from_parameters(vec![make_plain("text", json!("hello"))]);
305        let jp = data.json_parameters();
306        assert_eq!(jp["text"], "hello");
307    }
308
309    // ------------------------------------------------------------------
310    // json_payload
311    // ------------------------------------------------------------------
312
313    #[test]
314    fn json_payload_round_trips() {
315        let data = RequestData::from_parameters(vec![
316            make_plain("chat_id", json!(99)),
317            make_plain("text", json!("world")),
318        ]);
319        let payload = data.json_payload();
320        let parsed: serde_json::Value = serde_json::from_slice(&payload).unwrap();
321        assert_eq!(parsed["chat_id"], json!("99"));
322        assert_eq!(parsed["text"], json!("world"));
323    }
324
325    // ------------------------------------------------------------------
326    // url_encoded_parameters
327    // ------------------------------------------------------------------
328
329    #[test]
330    fn url_encoded_basic() {
331        let data = RequestData::from_parameters(vec![make_plain("key", json!("val"))]);
332        assert!(data.url_encoded_parameters().contains("key=val"));
333    }
334
335    #[test]
336    fn url_encoded_spaces_encoded_as_percent20() {
337        let data = RequestData::from_parameters(vec![make_plain("text", json!("hello world"))]);
338        let encoded = data.url_encoded_parameters();
339        assert!(encoded.contains("hello%20world"), "got: {encoded}");
340    }
341
342    // ------------------------------------------------------------------
343    // parametrized_url
344    // ------------------------------------------------------------------
345
346    #[test]
347    fn parametrized_url_has_question_mark() {
348        let data = RequestData::from_parameters(vec![make_plain("x", json!("1"))]);
349        let url = data.parametrized_url("https://example.com/api");
350        assert!(url.starts_with("https://example.com/api?"));
351    }
352
353    // ------------------------------------------------------------------
354    // multipart_data
355    // ------------------------------------------------------------------
356
357    #[test]
358    fn multipart_data_none_without_files() {
359        let data = RequestData::from_parameters(vec![make_plain("chat_id", json!(1))]);
360        assert!(data.multipart_data().is_none());
361    }
362
363    #[test]
364    fn multipart_data_includes_file_bytes() {
365        let file = InputFileRef::direct(vec![0xDE, 0xAD, 0xBE, 0xEF]);
366        let p = RequestParameter::file_only("sticker", file);
367        let data = RequestData::from_parameters(vec![p]);
368        let parts = data.multipart_data().unwrap();
369        let part = parts.get("sticker").expect("part named 'sticker'");
370        assert_eq!(part.bytes, vec![0xDE, 0xAD, 0xBE, 0xEF]);
371    }
372
373    #[test]
374    fn multipart_data_uses_attach_name_as_key() {
375        let file = InputFileRef::with_attach_name("media0", vec![0x01]);
376        let p = RequestParameter::with_files("media", json!("attach://media0"), vec![file]);
377        let data = RequestData::from_parameters(vec![p]);
378        let parts = data.multipart_data().unwrap();
379        assert!(
380            parts.contains_key("media0"),
381            "expected key 'media0', got: {parts:?}"
382        );
383    }
384
385    // ------------------------------------------------------------------
386    // percent_encode helper
387    // ------------------------------------------------------------------
388
389    #[test]
390    fn percent_encode_unreserved_unchanged() {
391        assert_eq!(percent_encode("abc-_.~"), "abc-_.~");
392    }
393
394    #[test]
395    fn percent_encode_space() {
396        assert_eq!(percent_encode(" "), "%20");
397    }
398
399    #[test]
400    fn percent_encode_ampersand() {
401        assert_eq!(percent_encode("a&b"), "a%26b");
402    }
403}