From 3bd8d3fb999c10fa9b2656feda531424d7f55ada Mon Sep 17 00:00:00 2001 From: Abdulla Abdurakhmanov Date: Sat, 27 Jan 2024 14:57:48 +0100 Subject: [PATCH] Remove obsolete form encoded upload. Update docs for 2.0. (#238) * Fixed content type for file upload * AsRef instead of ToString * Multipart upload separate module * Remove URL encoded uploader * Docs update --- Cargo.toml | 1 - docs/src/events-api-axum.md | 2 +- docs/src/events-api-hyper.md | 2 +- docs/src/getting-started.md | 2 +- docs/src/hyper-connections-types.md | 2 +- docs/src/pagination-support.md | 2 +- docs/src/rate-control-and-retries.md | 4 +- docs/src/send-webhooks-messages.md | 2 +- docs/src/socket-mode.md | 2 +- docs/src/web-api.md | 4 +- examples/client.rs | 8 +- src/api/files.rs | 70 ++++++++--------- src/client.rs | 108 +++++---------------------- src/errors.rs | 32 -------- src/hyper_tokio/connector.rs | 66 +++------------- src/hyper_tokio/hyper_ext.rs | 53 ------------- src/lib.rs | 1 + src/multipart_form.rs | 70 +++++++++++++++++ 18 files changed, 150 insertions(+), 281 deletions(-) create mode 100644 src/multipart_form.rs diff --git a/Cargo.toml b/Cargo.toml index d5aa7f09..d085c6f3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -57,7 +57,6 @@ hyper-rustls = { version="0.26", features = ["rustls-native-certs", "http2"], op tokio-tungstenite = { version = "0.21.0", features = ["rustls-tls-native-roots"], optional = true } axum = { version = "0.7", optional = true } tower = { version = "0.4", optional = true } -serde_urlencoded = "0.7.1" [target.'cfg(not(windows))'.dependencies] signal-hook = { version = "0.3", default-features = false, features = ["extended-siginfo"], optional = true} diff --git a/docs/src/events-api-axum.md b/docs/src/events-api-axum.md index 31db9913..7d2fecae 100644 --- a/docs/src/events-api-axum.md +++ b/docs/src/events-api-axum.md @@ -68,7 +68,7 @@ fn test_error_handler( async fn test_server() -> Result<(), Box> { let client: Arc = - Arc::new(SlackClient::new(SlackClientHyperConnector::new())); + Arc::new(SlackClient::new(SlackClientHyperConnector::new()?)); let addr = std::net::SocketAddr::from(([127, 0, 0, 1], 8080)); info!("Loading server: {}", addr); diff --git a/docs/src/events-api-hyper.md b/docs/src/events-api-hyper.md index a519e5e3..ede5a852 100644 --- a/docs/src/events-api-hyper.md +++ b/docs/src/events-api-hyper.md @@ -55,7 +55,7 @@ async fn create_slack_events_listener_server() -> Result<(), Box Result<(), Box> { - let hyper_connector = SlackClientHyperConnector::new(); + let hyper_connector = SlackClientHyperConnector::new()?; let client = SlackClient::new(hyper_connector); let token_value: SlackApiTokenValue = "xoxb-89.....".into(); diff --git a/docs/src/rate-control-and-retries.md b/docs/src/rate-control-and-retries.md index 03732bac..cf0cfe80 100644 --- a/docs/src/rate-control-and-retries.md +++ b/docs/src/rate-control-and-retries.md @@ -9,7 +9,7 @@ By default, throttler *isn't* enabled, so you should enable it explicitly: use slack_morphism::prelude::*; let client = SlackClient::new( - SlackClientHyperConnector::new() + SlackClientHyperConnector::new()? .with_rate_control( SlackApiRateControlConfig::new() ) @@ -44,7 +44,7 @@ you need to specify `max_retries` in rate control params (default value is `0`): ```rust,noplaypen let client = SlackClient::new( - SlackClientHyperConnector::new() + SlackClientHyperConnector::new()? .with_rate_control( SlackApiRateControlConfig::new().with_max_retries(5) ), diff --git a/docs/src/send-webhooks-messages.md b/docs/src/send-webhooks-messages.md index 3bd2b2a8..d3e32f94 100644 --- a/docs/src/send-webhooks-messages.md +++ b/docs/src/send-webhooks-messages.md @@ -7,7 +7,7 @@ You can use `client..post_webhook_message` to post [Slack Incoming Webhook](http use slack_morphism::prelude::*; use url::Url; -let client = SlackClient::new(SlackClientHyperConnector::new()); +let client = SlackClient::new(SlackClientHyperConnector::new()?); // Your incoming webhook url from config or OAuth/events (ResponseURL) let webhook_url: Url = Url::parse("https://hooks.slack.com/services/...")?; diff --git a/docs/src/socket-mode.md b/docs/src/socket-mode.md index 64fbd27c..40e098a5 100644 --- a/docs/src/socket-mode.md +++ b/docs/src/socket-mode.md @@ -41,7 +41,7 @@ async fn test_push_events_sm_function( Ok(()) } -let client = Arc::new(SlackClient::new(SlackClientHyperConnector::new())); +let client = Arc::new(SlackClient::new(SlackClientHyperConnector::new()?)); let socket_mode_callbacks = SlackSocketModeListenerCallbacks::new() .with_command_events(test_command_events_function) diff --git a/docs/src/web-api.md b/docs/src/web-api.md index e55f5de6..98378e07 100644 --- a/docs/src/web-api.md +++ b/docs/src/web-api.md @@ -5,7 +5,7 @@ ```rust,noplaypen use slack_morphism::prelude::*; -let client = SlackClient::new( SlackClientHyperConnector::new() ); +let client = SlackClient::new( SlackClientHyperConnector::new()? ); ``` @@ -25,7 +25,7 @@ use slack_morphism::prelude::*; async fn example() -> Result<(), Box> { - let client = SlackClient::new(SlackClientHyperConnector::new()); + let client = SlackClient::new(SlackClientHyperConnector::new()?); // Create our Slack API token let token_value: SlackApiTokenValue = "xoxb-89.....".into(); diff --git a/examples/client.rs b/examples/client.rs index c032f03a..5b03e784 100644 --- a/examples/client.rs +++ b/examples/client.rs @@ -39,7 +39,7 @@ async fn test_post_message() -> Result<(), Box Result<(), Box ClientResult { - if let Some(file) = &req.file { + let maybe_file = req.binary_content.as_ref().map(|file_data| { let filename = req.filename.clone().unwrap_or("file".to_string()); let file_content_type = req.file_content_type.clone().unwrap_or_else(|| { let file_mime = mime_guess::MimeGuess::from_path(&filename).first_or_octet_stream(); file_mime.to_string() }); - self.http_session_api - .http_post_multipart_form( - "files.upload", - filename, - file_content_type, - file, - &vec![ - ( - "channels", - req.channels - .as_ref() - .map(|xs| { - xs.iter() - .map(|x| x.to_string()) - .collect::>() - .join(",") - }) - .as_ref(), - ), - ("filetype", req.filetype.as_ref().map(|x| x.value())), - ("initial_comment", req.initial_comment.as_ref()), - ("thread_ts", req.thread_ts.as_ref().map(|x| x.value())), - ("title", req.title.as_ref()), - ], - Some(&SLACK_TIER2_METHOD_CONFIG), - ) - .await - } else { - self.http_session_api - .http_post_form_urlencoded("files.upload", req, Some(&SLACK_TIER2_METHOD_CONFIG)) - .await - } + FileMultipartData { + name: filename, + content_type: file_content_type, + data: file_data.as_slice(), + } + }); + self.http_session_api + .http_post_multipart_form( + "files.upload", + maybe_file, + &vec![ + ( + "channels", + req.channels + .as_ref() + .map(|xs| { + xs.iter() + .map(|x| x.to_string()) + .collect::>() + .join(",") + }) + .as_ref(), + ), + ("content", req.content.as_ref()), + ("filename", req.filename.as_ref()), + ("filetype", req.filetype.as_ref().map(|x| x.value())), + ("initial_comment", req.initial_comment.as_ref()), + ("thread_ts", req.thread_ts.as_ref().map(|x| x.value())), + ("title", req.title.as_ref()), + ], + Some(&SLACK_TIER2_METHOD_CONFIG), + ) + .await } } @@ -70,12 +72,12 @@ pub struct SlackApiFilesUploadRequest { #[serde(serialize_with = "to_csv")] pub channels: Option>, pub content: Option, + pub binary_content: Option>, pub filename: Option, pub filetype: Option, pub initial_comment: Option, pub thread_ts: Option, pub title: Option, - pub file: Option>, pub file_content_type: Option, } diff --git a/src/client.rs b/src/client.rs index f1ef9ba9..3f39a13e 100644 --- a/src/client.rs +++ b/src/client.rs @@ -6,6 +6,7 @@ use crate::token::*; use crate::errors::SlackClientError; use crate::models::*; +use crate::multipart_form::FileMultipartData; use crate::ratectl::SlackApiMethodRateControlConfig; use futures_util::future::BoxFuture; use lazy_static::*; @@ -81,8 +82,8 @@ pub trait SlackClientHttpConnector { ) -> BoxFuture<'a, ClientResult> where RS: for<'de> serde::de::Deserialize<'de> + Send + 'a, - PT: std::iter::IntoIterator)> + Clone, - TS: std::string::ToString + 'p + 'a + Send, + PT: std::iter::IntoIterator)> + Clone, + TS: AsRef + 'p + Send, { let full_uri = SlackClientHttpApiUri::create_url_with_params( &SlackClientHttpApiUri::create_method_uri_path(method_relative_uri), @@ -119,73 +120,35 @@ pub trait SlackClientHttpConnector { self.http_post_uri(full_uri, request, context) } - fn http_post_uri_form_urlencoded<'a, RQ, RS>( - &'a self, - full_uri: Url, - request_body: &'a RQ, - context: SlackClientApiCallContext<'a>, - ) -> BoxFuture<'a, ClientResult> - where - RQ: serde::ser::Serialize + Send + Sync, - RS: for<'de> serde::de::Deserialize<'de> + Send + 'a + Send + 'a; - - fn http_post_form_urlencoded<'a, RQ, RS>( - &'a self, - method_relative_uri: &str, - request: &'a RQ, - context: SlackClientApiCallContext<'a>, - ) -> BoxFuture<'a, ClientResult> - where - RQ: serde::ser::Serialize + Send + Sync, - RS: for<'de> serde::de::Deserialize<'de> + Send + 'a, - { - let full_uri = SlackClientHttpApiUri::create_url( - &SlackClientHttpApiUri::create_method_uri_path(method_relative_uri), - ); - - self.http_post_uri_form_urlencoded(full_uri, request, context) - } - fn http_post_uri_multipart_form<'a, 'p, RS, PT, TS>( &'a self, full_uri: Url, - file_name: String, - file_content_type: String, - file_content: &'p [u8], + file: Option>, params: &'p PT, context: SlackClientApiCallContext<'a>, ) -> BoxFuture<'a, ClientResult> where RS: for<'de> serde::de::Deserialize<'de> + Send + 'a + Send + 'a, - PT: std::iter::IntoIterator)> + Clone, - TS: std::string::ToString + 'p + 'a + Send; + PT: std::iter::IntoIterator)> + Clone, + TS: AsRef + 'p + Send; fn http_post_multipart_form<'a, 'p, RS, PT, TS>( &'a self, method_relative_uri: &str, - file_name: String, - file_content_type: String, - file_content: &'p [u8], + file: Option>, params: &'p PT, context: SlackClientApiCallContext<'a>, ) -> BoxFuture<'a, ClientResult> where RS: for<'de> serde::de::Deserialize<'de> + Send + 'a, - PT: std::iter::IntoIterator)> + Clone, - TS: std::string::ToString + 'p + 'a + Send, + PT: std::iter::IntoIterator)> + Clone, + TS: AsRef + 'p + Send, { let full_uri = SlackClientHttpApiUri::create_url( &SlackClientHttpApiUri::create_method_uri_path(method_relative_uri), ); - self.http_post_uri_multipart_form( - full_uri, - file_name, - file_content_type, - file_content, - params, - context, - ) + self.http_post_uri_multipart_form(full_uri, file, params, context) } } @@ -237,13 +200,13 @@ impl SlackClientHttpApiUri { pub fn create_url_with_params<'p, PT, TS>(url_str: &str, params: &'p PT) -> Url where - PT: std::iter::IntoIterator)> + Clone, - TS: std::string::ToString + 'p, + PT: std::iter::IntoIterator)> + Clone, + TS: AsRef + 'p, { let url_query_params: Vec<(String, String)> = params .clone() .into_iter() - .filter_map(|(k, vo)| vo.map(|v| (k.to_string(), v.to_string()))) + .filter_map(|(k, vo)| vo.map(|v| (k.to_string(), v.as_ref().to_string()))) .collect(); Url::parse_with_params(url_str, url_query_params) @@ -328,8 +291,8 @@ where ) -> ClientResult where RS: for<'de> serde::de::Deserialize<'de> + Send, - PT: std::iter::IntoIterator)> + Clone, - TS: std::string::ToString + 'p + Send, + PT: std::iter::IntoIterator)> + Clone, + TS: AsRef + 'p + Send, { let context = SlackClientApiCallContext { rate_control_params, @@ -393,43 +356,17 @@ where .await } - pub async fn http_post_form_urlencoded( - &self, - method_relative_uri: &str, - request: &RQ, - rate_control_params: Option<&'a SlackApiMethodRateControlConfig>, - ) -> ClientResult - where - RQ: serde::ser::Serialize + Send + Sync, - RS: for<'de> serde::de::Deserialize<'de> + Send, - { - let context = SlackClientApiCallContext { - rate_control_params, - token: Some(self.token), - tracing_span: &self.span, - is_sensitive_url: false, - }; - - self.client - .http_api - .connector - .http_post_form_urlencoded(method_relative_uri, &request, context) - .await - } - pub async fn http_post_multipart_form<'p, RS, PT, TS>( &self, method_relative_uri: &str, - file_name: String, - file_content_type: String, - file_content: &'p [u8], + file: Option>, params: &'p PT, rate_control_params: Option<&'a SlackApiMethodRateControlConfig>, ) -> ClientResult where - PT: std::iter::IntoIterator)> + Clone, RS: for<'de> serde::de::Deserialize<'de> + Send, - TS: std::string::ToString + 'p + Send, + PT: std::iter::IntoIterator)> + Clone, + TS: AsRef + 'p + Send, { let context = SlackClientApiCallContext { rate_control_params, @@ -441,14 +378,7 @@ where self.client .http_api .connector - .http_post_multipart_form( - method_relative_uri, - file_name, - file_content_type, - file_content, - params, - context, - ) + .http_post_multipart_form(method_relative_uri, file, params, context) .await } } diff --git a/src/errors.rs b/src/errors.rs index 9a361a8a..7d308dc5 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -13,7 +13,6 @@ pub enum SlackClientError { EndOfStream(SlackClientEndOfStreamError), SystemError(SlackClientSystemError), ProtocolError(SlackClientProtocolError), - FormUrlEncodingError(SlackClientFormUrlEncodingError), SocketModeProtocolError(SlackClientSocketModeProtocolError), RateLimitError(SlackRateLimitError), } @@ -34,7 +33,6 @@ impl Display for SlackClientError { SlackClientError::HttpProtocolError(ref err) => err.fmt(f), SlackClientError::EndOfStream(ref err) => err.fmt(f), SlackClientError::ProtocolError(ref err) => err.fmt(f), - SlackClientError::FormUrlEncodingError(ref err) => err.fmt(f), SlackClientError::SocketModeProtocolError(ref err) => err.fmt(f), SlackClientError::SystemError(ref err) => err.fmt(f), SlackClientError::RateLimitError(ref err) => err.fmt(f), @@ -50,7 +48,6 @@ impl Error for SlackClientError { SlackClientError::HttpProtocolError(ref err) => Some(err), SlackClientError::EndOfStream(ref err) => Some(err), SlackClientError::ProtocolError(ref err) => Some(err), - SlackClientError::FormUrlEncodingError(ref err) => Some(err), SlackClientError::SocketModeProtocolError(ref err) => Some(err), SlackClientError::SystemError(ref err) => Some(err), SlackClientError::RateLimitError(ref err) => Some(err), @@ -141,25 +138,6 @@ impl Display for SlackClientProtocolError { impl std::error::Error for SlackClientProtocolError {} -#[derive(Debug, Builder)] -pub struct SlackClientFormUrlEncodingError { - pub serialization_error: serde_urlencoded::ser::Error, - pub json_body: Option, -} - -impl Display for SlackClientFormUrlEncodingError { - fn fmt(&self, f: &mut Formatter) -> std::fmt::Result { - write!( - f, - "Slack JSON to URL serialization protocol error: {}. Body: '{}'", - self.serialization_error, - SlackClientError::option_to_string(&self.json_body) - ) - } -} - -impl std::error::Error for SlackClientFormUrlEncodingError {} - #[derive(Debug, PartialEq, Eq, Clone, Builder)] pub struct SlackClientSocketModeProtocolError { pub message: String, @@ -233,13 +211,3 @@ pub fn map_serde_error(err: serde_json::Error, tried_to_parse: Option<&str>) -> SlackClientProtocolError::new(err).opt_json_body(tried_to_parse.map(|s| s.to_string())), ) } - -pub fn map_serde_urlencoded_error( - err: serde_urlencoded::ser::Error, - tried_to_parse: Option<&str>, -) -> SlackClientError { - SlackClientError::FormUrlEncodingError( - SlackClientFormUrlEncodingError::new(err) - .opt_json_body(tried_to_parse.map(|s| s.to_string())), - ) -} diff --git a/src/hyper_tokio/connector.rs b/src/hyper_tokio/connector.rs index c028b6eb..01f6a2d2 100644 --- a/src/hyper_tokio/connector.rs +++ b/src/hyper_tokio/connector.rs @@ -13,6 +13,10 @@ use hyper_util::client::legacy::*; use hyper_util::rt::TokioExecutor; use rvstruct::ValueStruct; +use crate::hyper_tokio::multipart_form::{ + create_multipart_file_content, generate_multipart_boundary, +}; +use crate::multipart_form::FileMultipartData; use crate::prelude::hyper_ext::HyperExtensions; use crate::ratectl::SlackApiRateControlConfig; use std::hash::Hash; @@ -387,73 +391,21 @@ impl SlackClientHttpConnect .boxed() } - fn http_post_uri_form_urlencoded<'a, RQ, RS>( - &'a self, - full_uri: Url, - request_body: &'a RQ, - context: SlackClientApiCallContext<'a>, - ) -> BoxFuture<'a, ClientResult> - where - RQ: serde::ser::Serialize + Send + Sync, - RS: for<'de> serde::de::Deserialize<'de> + Send + 'a + Send + 'a, - { - let context_token = context.token; - - async move { - let post_url_form = serde_urlencoded::to_string(request_body) - .map_err(|err| map_serde_urlencoded_error(err, None))?; - - let response_body = self - .send_rate_controlled_request( - || { - let http_request = HyperExtensions::create_http_request( - full_uri.clone(), - hyper::http::Method::POST, - ) - .header("content-type", "application/x-www-form-urlencoded"); - - let toke_body_prefix = context_token.map_or_else(String::new, |token| { - format!("token={}&", token.token_value.value()) - }); - let full_body = toke_body_prefix + &post_url_form; - http_request - .body(Full::new(full_body.into()).boxed()) - .map_err(|e| e.into()) - }, - context, - None, - 0, - ) - .await?; - - Ok(response_body) - } - .boxed() - } - fn http_post_uri_multipart_form<'a, 'p, RS, PT, TS>( &'a self, full_uri: Url, - file_name: String, - file_content_type: String, - file_content: &'p [u8], + file: Option>, params: &'p PT, context: SlackClientApiCallContext<'a>, ) -> BoxFuture<'a, ClientResult> where RS: for<'de> serde::de::Deserialize<'de> + Send + 'a + Send + 'a, - PT: std::iter::IntoIterator)> + Clone, - TS: std::string::ToString + 'p + 'a + Send, + PT: std::iter::IntoIterator)> + Clone, + TS: AsRef + 'p + Send, { let context_token = context.token; - let boundary = HyperExtensions::generate_multipart_boundary(); - match HyperExtensions::create_multipart_file_content( - params, - boundary.as_str(), - file_name.as_str(), - file_content_type.as_str(), - file_content, - ) { + let boundary = generate_multipart_boundary(); + match create_multipart_file_content(params, boundary.as_str(), file) { Ok(file_bytes) => self .send_rate_controlled_request( move || { diff --git a/src/hyper_tokio/hyper_ext.rs b/src/hyper_tokio/hyper_ext.rs index 2a051a57..322e1387 100644 --- a/src/hyper_tokio/hyper_ext.rs +++ b/src/hyper_tokio/hyper_ext.rs @@ -125,57 +125,4 @@ impl HyperExtensions { _ => Err(Box::new(SlackEventAbsentSignatureError::new())), } } - - pub fn generate_multipart_boundary() -> String { - format!( - "----WebKitFormBoundarySlackMorphismRust{}", - chrono::Utc::now().timestamp() - ) - } - - pub fn create_multipart_file_content<'p, PT, TS>( - fields: &'p PT, - multipart_boundary: &str, - file_name: &str, - file_content_type: &str, - file_content: &[u8], - ) -> AnyStdResult - where - PT: std::iter::IntoIterator)> + Clone, - TS: std::string::ToString + 'p + Send, - { - let mut output = BytesMut::with_capacity(file_content.len() + 512); - output.write_str("\r\n")?; - output.write_str("\r\n")?; - output.write_str("--")?; - output.write_str(multipart_boundary)?; - output.write_str("\r\n")?; - output.write_str(&format!( - "Content-Disposition: form-data; name=\"file\"; filename=\"{}\"", - file_name - ))?; - output.write_str(&format!("Content-Type: {}", file_content_type))?; - output.write_str("\r\n")?; - output.write_str("\r\n")?; - output.put_slice(file_content); - - for (k, mv) in fields.clone().into_iter() { - if let Some(v) = mv { - output.write_str("\r\n")?; - output.write_str("--")?; - output.write_str(multipart_boundary)?; - output.write_str("\r\n")?; - output.write_str(&format!("Content-Disposition: form-data; name=\"{}\"", k))?; - output.write_str("\r\n")?; - output.write_str("\r\n")?; - output.write_str(&v.to_string())?; - } - } - - output.write_str("\r\n")?; - output.write_str("--")?; - output.write_str(multipart_boundary)?; - - Ok(output.freeze()) - } } diff --git a/src/lib.rs b/src/lib.rs index cae1a839..9de2d36a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -101,6 +101,7 @@ mod scroller; pub mod signature_verifier; pub mod socket_mode; +mod multipart_form; mod token; #[cfg(feature = "hyper")] diff --git a/src/multipart_form.rs b/src/multipart_form.rs new file mode 100644 index 00000000..5201f9f6 --- /dev/null +++ b/src/multipart_form.rs @@ -0,0 +1,70 @@ +use crate::AnyStdResult; +use bytes::{BufMut, Bytes, BytesMut}; +use std::fmt::Write; + +pub struct FileMultipartData<'a> { + pub name: String, + pub content_type: String, + pub data: &'a [u8], +} + +pub(crate) fn generate_multipart_boundary() -> String { + format!( + "----WebKitFormBoundarySlackMorphismRust{}", + chrono::Utc::now().timestamp() + ) +} + +pub(crate) fn create_multipart_file_content<'p, PT, TS>( + fields: &'p PT, + multipart_boundary: &str, + file: Option>, +) -> AnyStdResult +where + PT: std::iter::IntoIterator)> + Clone, + TS: AsRef + 'p + Send, +{ + let capacity = file.as_ref().map(|x| x.data.len()).unwrap_or(0) + 1024; + let mut output = BytesMut::with_capacity(capacity); + output.write_str("\r\n")?; + + if let Some(file_to_upload) = file { + output.write_str("\r\n")?; + output.write_str("--")?; + output.write_str(multipart_boundary)?; + output.write_str("\r\n")?; + output.write_str(&format!( + "Content-Disposition: form-data; name=\"file\"; filename=\"{}\"", + file_to_upload.name + ))?; + output.write_str("\r\n")?; + output.write_str(&format!("Content-Type: {}", file_to_upload.content_type))?; + output.write_str("\r\n")?; + output.write_str(&format!("Content-Length: {}", file_to_upload.data.len()))?; + output.write_str("\r\n")?; + output.write_str("\r\n")?; + output.put_slice(file_to_upload.data); + } + + for (k, mv) in fields.clone().into_iter() { + if let Some(v) = mv { + let vs = v.as_ref(); + output.write_str("\r\n")?; + output.write_str("--")?; + output.write_str(multipart_boundary)?; + output.write_str("\r\n")?; + output.write_str(&format!("Content-Disposition: form-data; name=\"{}\"", k))?; + output.write_str("\r\n")?; + output.write_str(&format!("Content-Length: {}", vs.len()))?; + output.write_str("\r\n")?; + output.write_str("\r\n")?; + output.write_str(vs)?; + } + } + + output.write_str("\r\n")?; + output.write_str("--")?; + output.write_str(multipart_boundary)?; + + Ok(output.freeze()) +}