Skip to main content

rust_tg_bot_raw/request/
request_parameter.rs

1//! A single parameter in a Telegram Bot API request.
2//!
3//! This mirrors `telegram.request.RequestParameter` from python-telegram-bot.  The
4//! Python class handles many Telegram-specific helper types (InputFile, InputMedia,
5//! TelegramObject, …) during conversion.  In the Rust port those domain types will
6//! eventually convert themselves into a `RequestParameter` via `From`/`Into`
7//! implementations; until the full type layer exists the value is kept as a raw
8//! `serde_json::Value` together with an optional list of attached binary files.
9
10use std::borrow::Cow;
11
12use serde_json::Value;
13
14/// Metadata for a single file that is uploaded as part of a multipart form.
15///
16/// The `attach_name` is the name used in the `attach://<name>` URI scheme.
17/// When it is `None` the file must be sent as the sole binary part and the
18/// parameter value itself is omitted from the JSON payload (matching the
19/// Python `if value.attach_uri` branch).
20#[derive(Debug, Clone)]
21pub struct InputFileRef {
22    /// Multipart form part name.  When present the JSON value is
23    /// `"attach://<attach_name>"`.  When absent the file is sent directly and
24    /// the parameter's JSON value is `None`.
25    pub attach_name: Option<String>,
26    /// Raw file bytes.
27    pub bytes: Vec<u8>,
28    /// Optional MIME type (defaults to `application/octet-stream`).
29    pub mime_type: Option<String>,
30    /// Optional file name hint sent to Telegram.
31    pub file_name: Option<String>,
32}
33
34impl InputFileRef {
35    /// Build an [`InputFileRef`] that is uploaded directly (no attach URI).
36    pub fn direct(bytes: Vec<u8>) -> Self {
37        Self {
38            attach_name: None,
39            bytes,
40            mime_type: None,
41            file_name: None,
42        }
43    }
44
45    /// Build an [`InputFileRef`] uploaded via an `attach://<name>` URI.
46    pub fn with_attach_name(attach_name: impl Into<String>, bytes: Vec<u8>) -> Self {
47        Self {
48            attach_name: Some(attach_name.into()),
49            bytes,
50            mime_type: None,
51            file_name: None,
52        }
53    }
54
55    /// The `attach://` URI string, if this file has an attach name.
56    pub fn attach_uri(&self) -> Option<String> {
57        self.attach_name.as_deref().map(|n| format!("attach://{n}"))
58    }
59
60    /// The MIME type string used when building the multipart part, falling back
61    /// to `application/octet-stream`.
62    pub fn effective_mime(&self) -> &str {
63        self.mime_type
64            .as_deref()
65            .unwrap_or("application/octet-stream")
66    }
67}
68
69/// A single named parameter sent to the Telegram Bot API.
70///
71/// # Relationship to Python source
72///
73/// | Python attribute | Rust field |
74/// |---|---|
75/// | `name` | [`RequestParameter::name`] |
76/// | `value` | [`RequestParameter::value`] |
77/// | `input_files` | [`RequestParameter::input_files`] |
78///
79/// The `json_value` and `multipart_data` Python properties are implemented as
80/// methods here: [`RequestParameter::json_value`] and
81/// [`RequestParameter::multipart_data`].
82#[derive(Debug, Clone)]
83pub struct RequestParameter {
84    /// The API parameter name, e.g. `"chat_id"` or `"photo"`.
85    pub name: Cow<'static, str>,
86
87    /// The JSON-serialisable value.  `None` is used when the parameter consists
88    /// solely of a file that must be uploaded without an attach URI (the Python
89    /// branch `return None, [value]`).
90    pub value: Option<Value>,
91
92    /// Files to upload together with this parameter.
93    pub input_files: Option<Vec<InputFileRef>>,
94}
95
96impl RequestParameter {
97    /// Construct a plain (non-file) parameter.
98    ///
99    /// ```
100    /// use rust_tg_bot_raw::request::request_parameter::RequestParameter;
101    /// use serde_json::json;
102    ///
103    /// let p = RequestParameter::new("chat_id", json!(12345));
104    /// assert_eq!(p.json_value().unwrap(), "12345");
105    /// ```
106    pub fn new(name: impl Into<Cow<'static, str>>, value: impl Into<Value>) -> Self {
107        Self {
108            name: name.into(),
109            value: Some(value.into()),
110            input_files: None,
111        }
112    }
113
114    /// Construct a parameter that carries attached files alongside a JSON value.
115    pub fn with_files(
116        name: impl Into<Cow<'static, str>>,
117        value: impl Into<Value>,
118        files: Vec<InputFileRef>,
119    ) -> Self {
120        Self {
121            name: name.into(),
122            value: Some(value.into()),
123            input_files: Some(files),
124        }
125    }
126
127    /// Construct a file-only parameter where the JSON value is absent (the
128    /// Python `return None, [value]` case).
129    pub fn file_only(name: impl Into<Cow<'static, str>>, file: InputFileRef) -> Self {
130        Self {
131            name: name.into(),
132            value: None,
133            input_files: Some(vec![file]),
134        }
135    }
136
137    /// The JSON-encoded string representation of [`Self::value`], or `None`
138    /// when the value is absent.
139    ///
140    /// Mirrors `RequestParameter.json_value` in Python:
141    /// - `str` values are returned as-is (without extra JSON quotes).
142    /// - All other values are serialised with `serde_json::to_string`.
143    ///
144    /// ```
145    /// use rust_tg_bot_raw::request::request_parameter::RequestParameter;
146    /// use serde_json::json;
147    ///
148    /// let string_param = RequestParameter::new("text", json!("hello"));
149    /// // String values are returned verbatim, not double-encoded.
150    /// assert_eq!(string_param.json_value().unwrap(), "hello");
151    ///
152    /// let int_param = RequestParameter::new("count", json!(42));
153    /// assert_eq!(int_param.json_value().unwrap(), "42");
154    ///
155    /// let bool_param = RequestParameter::new("enabled", json!(true));
156    /// assert_eq!(bool_param.json_value().unwrap(), "true");
157    /// ```
158    pub fn json_value(&self) -> Option<String> {
159        match &self.value {
160            None => None,
161            Some(Value::String(s)) => Some(s.clone()),
162            Some(v) => Some(v.to_string()),
163        }
164    }
165
166    /// Produce the multipart parts contributed by this parameter's files.
167    ///
168    /// Returns `None` when there are no attached files.
169    ///
170    /// The returned iterator yields `(part_name, file)` pairs where `part_name`
171    /// is either the file's `attach_name` or, when absent, the parameter name.
172    /// This mirrors the Python dict comprehension:
173    /// ```python
174    /// {(input_file.attach_name or self.name): input_file.field_tuple ...}
175    /// ```
176    pub fn multipart_data(&self) -> Option<Vec<(String, &InputFileRef)>> {
177        let files = self.input_files.as_ref()?;
178        let parts: Vec<(String, &InputFileRef)> = files
179            .iter()
180            .map(|f| {
181                let part_name = f
182                    .attach_name
183                    .clone()
184                    .unwrap_or_else(|| self.name.as_ref().to_owned());
185                (part_name, f)
186            })
187            .collect();
188        Some(parts)
189    }
190}
191
192#[cfg(test)]
193mod tests {
194    use serde_json::json;
195
196    use super::*;
197
198    #[test]
199    fn json_value_string_not_double_encoded() {
200        let p = RequestParameter::new("text", json!("hello world"));
201        assert_eq!(p.json_value().unwrap(), "hello world");
202    }
203
204    #[test]
205    fn json_value_integer() {
206        let p = RequestParameter::new("chat_id", json!(99));
207        assert_eq!(p.json_value().unwrap(), "99");
208    }
209
210    #[test]
211    fn json_value_bool() {
212        let p = RequestParameter::new("disable_notification", json!(false));
213        assert_eq!(p.json_value().unwrap(), "false");
214    }
215
216    #[test]
217    fn json_value_none_when_file_only() {
218        let file = InputFileRef::direct(vec![0u8, 1, 2]);
219        let p = RequestParameter::file_only("photo", file);
220        assert!(p.json_value().is_none());
221    }
222
223    #[test]
224    fn json_value_object() {
225        let p = RequestParameter::new("reply_markup", json!({"inline_keyboard": []}));
226        let s = p.json_value().unwrap();
227        let reparsed: serde_json::Value = serde_json::from_str(&s).unwrap();
228        assert_eq!(reparsed["inline_keyboard"], json!([]));
229    }
230
231    #[test]
232    fn multipart_data_none_for_plain_param() {
233        let p = RequestParameter::new("chat_id", json!(1));
234        assert!(p.multipart_data().is_none());
235    }
236
237    #[test]
238    fn multipart_data_uses_attach_name() {
239        let file = InputFileRef::with_attach_name("my_file", vec![0xff]);
240        let p = RequestParameter::with_files("document", json!("attach://my_file"), vec![file]);
241        let parts = p.multipart_data().unwrap();
242        assert_eq!(parts.len(), 1);
243        assert_eq!(parts[0].0, "my_file");
244    }
245
246    #[test]
247    fn multipart_data_falls_back_to_param_name() {
248        let file = InputFileRef::direct(vec![0xde, 0xad]);
249        let p = RequestParameter::file_only("photo", file);
250        let parts = p.multipart_data().unwrap();
251        assert_eq!(parts.len(), 1);
252        assert_eq!(parts[0].0, "photo");
253    }
254
255    #[test]
256    fn attach_uri_present_when_name_set() {
257        let file = InputFileRef::with_attach_name("vid", vec![]);
258        assert_eq!(file.attach_uri().unwrap(), "attach://vid");
259    }
260
261    #[test]
262    fn attach_uri_none_for_direct_file() {
263        let file = InputFileRef::direct(vec![]);
264        assert!(file.attach_uri().is_none());
265    }
266}