Skip to content
Draft
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
a5bd40f
feat: send files directly from bytes
EdJoPaTo Feb 20, 2025
bc10f7c
refactor: less error-prone new_params without InputFile
EdJoPaTo Feb 20, 2025
d56bf46
refactor: move internal method to the end of the impl
EdJoPaTo Feb 21, 2025
1550453
Merge branch 'master' into edjopato/filehandling
EdJoPaTo Feb 21, 2025
44bcac6
Merge branch 'master' into edjopato/filehandling
EdJoPaTo Feb 23, 2025
3feeaa6
fix(client-ureq): add back mime_guess
EdJoPaTo Feb 23, 2025
cb87c98
Merge remote-tracking branch 'origin/master' into edjopato/filehandling
EdJoPaTo Feb 26, 2025
64625c3
test(client-ureq): use include_bytes for more realistic dummyfile
EdJoPaTo Feb 26, 2025
c94e76c
fix(macros): InputFile no longer has impl From
EdJoPaTo Feb 26, 2025
7ccfa89
Merge branch 'master' into edjopato/filehandling
EdJoPaTo Feb 27, 2025
a4e50b7
Merge remote-tracking branch 'origin/master' into edjopato/filehandling
EdJoPaTo Mar 10, 2025
c5629e0
refactor: use simpler io::Error::other
EdJoPaTo Mar 10, 2025
1ff7f49
perf(files): keep PathBuf variant as its optimized by multistream
EdJoPaTo Mar 11, 2025
a168c9b
docs(readme): remove relatively clear upload docs
EdJoPaTo Mar 11, 2025
02a1936
Merge remote-tracking branch 'origin/master' into edjopato/filehandling
EdJoPaTo Mar 17, 2025
dc2b8d7
Merge remote-tracking branch 'origin/master' into edjopato/filehandling
EdJoPaTo Apr 8, 2025
ac2aaf2
refactor: generalize methods with file uploads
EdJoPaTo Apr 8, 2025
414c573
docs(traits): add docs for the manually implemented methods
EdJoPaTo Apr 8, 2025
80f83ec
feat(files): move instead of clone
EdJoPaTo Apr 8, 2025
e616f3b
fixup! feat(files): move instead of clone
EdJoPaTo Apr 8, 2025
6676ee3
perf: parse directly into map
EdJoPaTo Apr 8, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 8 additions & 7 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,11 @@ categories = ["web-programming::http-client"]
all-features = true

[features]
client-reqwest = ["trait-async", "dep:reqwest", "dep:tokio", "dep:serde_json"]
client-reqwest = ["trait-async", "dep:reqwest", "dep:serde_json"]
client-ureq = ["trait-sync", "dep:ureq", "dep:multipart", "dep:mime_guess", "dep:serde_json"]
trait-async = ["dep:async-trait"]
trait-sync = []
inputfile-read-tokio = ["dep:tokio", "tokio/fs"]

[lints.rust]
unsafe_code = "forbid"
Expand All @@ -46,6 +47,7 @@ serde = { version = "1.0.157", features = ["derive"] }
serde_json = { version = "1.0.45", optional = true }
serde_with = { version = "3.0.0", default-features = false, features = ["macros"] }
thiserror = "2"
tokio = { version = "1", optional = true }
ureq = { version = "3.0.0", optional = true, default-features = false, features = ["rustls"] }

[target.'cfg(not(target_arch = "wasm32"))'.dependencies.reqwest]
Expand All @@ -60,11 +62,6 @@ default-features = false
features = ["multipart", "stream"]
optional = true

[target.'cfg(not(target_arch = "wasm32"))'.dependencies.tokio]
version = "1"
features = ["fs"]
optional = true

[dev-dependencies]
isahc = "1"
mockito = "1.0"
Expand All @@ -87,6 +84,10 @@ required-features = ["client-ureq"]
name = "inline_keyboard"
required-features = ["client-ureq"]

[[example]]
name = "file_upload"
required-features = ["client-ureq"]

[[example]]
name = "custom_client"
required-features = ["client-ureq"]
Expand All @@ -101,7 +102,7 @@ required-features = ["client-reqwest"]

[[example]]
name = "async_file_upload"
required-features = ["client-reqwest"]
required-features = ["client-reqwest", "inputfile-read-tokio"]

[[example]]
name = "async_custom_client"
Expand Down
10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,9 @@ Without enabling any additional features this crate will only ship with Telegram
- `client-ureq` - a blocking HTTP API client based on `ureq`
- `trait-sync` - a blocking API trait, it's included in the `client-ureq` feature. It may be useful for people who want to create a custom blocking client (for example, replacing an HTTP client)
- async
- `client-reqwest` - an async HTTP API client based on `reqwest`. This client partially supports wasm32, but file uploads are currently not supported there.
- `client-reqwest` - an async HTTP API client based on `reqwest`. This client supports wasm32
- `trait-async` - an async API trait, it's used in the `client-reqwest`. It may be useful for people who want to create a custom async client
- `inputfile-read-tokio` - helper function to read an `InputFile` from a file system with `tokio::fs::read`

For example for the async client add the following line to your `Cargo.toml` file:

Expand Down Expand Up @@ -116,7 +117,7 @@ See more examples in the [`examples`](https://github.com/ayrat555/frankenstein/t

### Uploading files

Some methods in the API allow uploading files. In the Frankenstein for this `FileUpload` enum is used:
Some methods in the API allow uploading files. In Frankenstein the `FileUpload` enum is used:

```rust
pub enum FileUpload {
Expand All @@ -125,7 +126,8 @@ pub enum FileUpload {
}

pub struct InputFile {
path: std::path::PathBuf
bytes: Vec<u8>,
file_name: String,
}
```

Expand All @@ -134,6 +136,8 @@ It has two variants:
- `FileUpload::String` is used to pass the ID of the already uploaded file
- `FileUpload::InputFile` is used to upload a new file using multipart upload.

You can use the helper functions `InputFile::read_std(Path)` or `InputFile::read_tokio(Path)` for reading from the file system.

### Documentation

Frankenstein implements all Telegram bot API methods. To see which parameters you should pass, check the [official Telegram Bot API documentation](https://core.telegram.org/bots/api#available-methods) or [docs.rs/frankenstein](https://docs.rs/frankenstein/0.39.2/frankenstein/trait.TelegramApi.html#provided-methods)
Expand Down
5 changes: 2 additions & 3 deletions examples/api_trait_implementation.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
use std::path::PathBuf;

use frankenstein::api_params::SendMessageParams;
use frankenstein::input_file::InputFile;
use frankenstein::response::ErrorResponse;
use frankenstein::TelegramApi;
use isahc::prelude::*;
Expand Down Expand Up @@ -91,7 +90,7 @@ impl TelegramApi for MyApiClient {
&self,
_method: &str,
_params: Params,
_files: Vec<(&str, PathBuf)>,
_files: Vec<(&str, &InputFile)>,
) -> Result<Output, Self::Error>
where
Params: serde::ser::Serialize + std::fmt::Debug,
Expand Down
6 changes: 5 additions & 1 deletion examples/async_file_upload.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use frankenstein::api_params::SendPhotoParams;
use frankenstein::client_reqwest::Bot;
use frankenstein::input_file::InputFile;
use frankenstein::AsyncTelegramApi;

#[tokio::main]
Expand All @@ -12,7 +13,10 @@ async fn main() {

let bot = Bot::new(&token);

let file = std::path::PathBuf::from("./frankenstein_logo.png");
let file = InputFile::read_tokio_fs("./frankenstein_logo.png")
.await
.expect("Should be able to read file");
println!("File size: {}", file.bytes.len());

let params = SendPhotoParams::builder()
.chat_id(chat_id)
Expand Down
31 changes: 31 additions & 0 deletions examples/file_upload.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
use frankenstein::api_params::SendPhotoParams;
use frankenstein::client_ureq::Bot;
use frankenstein::input_file::InputFile;
use frankenstein::TelegramApi;

fn main() {
let token = std::env::var("BOT_TOKEN").expect("Should have BOT_TOKEN as environment variable");
let chat_id = std::env::var("TARGET_CHAT")
.expect("Should have TARGET_CHAT as environment variable")
.parse::<i64>()
.expect("TARGET_CHAT should be i64");

let bot = Bot::new(&token);

let file = InputFile::read_std("./frankenstein_logo.png").expect("Should be able to read file");
println!("File size: {}", file.bytes.len());

let params = SendPhotoParams::builder()
.chat_id(chat_id)
.photo(file)
.build();
match bot.send_photo(&params) {
Ok(response) => {
println!("Photo was uploaded successfully");
dbg!(response);
}
Err(error) => {
eprintln!("Failed to upload photo: {error:?}");
}
}
}
65 changes: 27 additions & 38 deletions src/client_reqwest.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
use std::path::PathBuf;

use async_trait::async_trait;
use bon::Builder;
use serde_json::Value;

use crate::input_file::InputFile;
use crate::trait_async::AsyncTelegramApi;
use crate::Error;

Expand Down Expand Up @@ -93,51 +93,40 @@ impl AsyncTelegramApi for Bot {
&self,
method: &str,
params: Params,
files: Vec<(&str, PathBuf)>,
files: Vec<(&str, &InputFile)>,
) -> Result<Output, Self::Error>
where
Params: serde::ser::Serialize + std::fmt::Debug + std::marker::Send,
Output: serde::de::DeserializeOwned,
{
#[cfg(not(target_arch = "wasm32"))]
{
use reqwest::multipart;
use serde_json::Value;

let json_string = crate::json::encode(&params)?;
let json_struct: Value = serde_json::from_str(&json_string).unwrap();

let file_keys: Vec<&str> = files.iter().map(|(key, _)| *key).collect();

let mut form = multipart::Form::new();
for (key, val) in json_struct.as_object().unwrap() {
if !file_keys.contains(&key.as_str()) {
let val = match val {
Value::String(val) => val.to_string(),
other => other.to_string(),
};

form = form.text(key.clone(), val);
}
let json_string = crate::json::encode(&params)?;
let json_struct: Value = serde_json::from_str(&json_string).unwrap();

let file_keys: Vec<&str> = files.iter().map(|(key, _)| *key).collect();

let mut form = reqwest::multipart::Form::new();
for (key, val) in json_struct.as_object().unwrap() {
if !file_keys.contains(&key.as_str()) {
let val = match val {
Value::String(val) => val.to_string(),
other => other.to_string(),
};
form = form.text(key.clone(), val);
}
}

for (parameter_name, file_path) in files {
let file = tokio::fs::File::open(&file_path)
.await
.map_err(Error::ReadFile)?;
let file_name = file_path.file_name().unwrap().to_string_lossy().to_string();
let part = multipart::Part::stream(file).file_name(file_name);
form = form.part(parameter_name.to_owned(), part);
}

let url = format!("{}/{method}", self.api_url);

let response = self.client.post(url).multipart(form).send().await?;
Self::decode_response(response).await
for (parameter_name, input_file) in files {
// The reqwest::multipart stuff requires 'static which we can not grant here.
// So we provide owned data.
let part = reqwest::multipart::Part::bytes(input_file.bytes.clone())
.file_name(input_file.file_name.clone());
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This bytes.clone() is kinda horrible. Even with the rest of this approach being refactored into references, reqwest still requires a clone here.
And for 1.5 GB file data, this is definitely a lot.

The best we could do would require the ownership and pass it over to reqwest so it's never cloned by frankenstein and only explicitly by the user of frankenstein when needed.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ureq would work with references while reqwest doesn't. But frankenstein requires either both with references or none.

form = form.part(parameter_name.to_owned(), part);
}

#[cfg(target_arch = "wasm32")]
Err(Error::WasmHasNoFileSupportYet)
let url = format!("{}/{method}", self.api_url);

let response = self.client.post(url).multipart(form).send().await?;
Self::decode_response(response).await
}
}

Expand Down
Loading