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}