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}