diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3f39a16d..a3fdd4db 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
- Structured bodies can now be defined with tags on the `body` field of a recipe, making it more convenient to construct bodies of common types. Supported types are:
- `!json` [#242](https://github.com/LucasPickering/slumber/issues/242)
- `!form_urlencoded` [#244](https://github.com/LucasPickering/slumber/issues/244)
+ - `!form_multipart` [#243](https://github.com/LucasPickering/slumber/issues/243)
- [See docs](https://slumber.lucaspickering.me/book/api/request_collection/recipe_body.html) for usage instructions
- Support multiple instances of the same query param [#245](https://github.com/LucasPickering/slumber/issues/245) (@maksimowiczm)
- Query params can now be defined as a list of `=` entries
diff --git a/Cargo.lock b/Cargo.lock
index cbd68fec..3123e481 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1151,6 +1151,16 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
+[[package]]
+name = "mime_guess"
+version = "2.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4192263c238a5f0d0c6bfd21f336a313a4ce1c450542449ca191bb657b4642ef"
+dependencies = [
+ "mime",
+ "unicase",
+]
+
[[package]]
name = "minimal-lexical"
version = "0.2.1"
@@ -1615,6 +1625,7 @@ dependencies = [
"js-sys",
"log",
"mime",
+ "mime_guess",
"once_cell",
"percent-encoding",
"pin-project-lite",
@@ -2365,6 +2376,15 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
+[[package]]
+name = "unicase"
+version = "2.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f7d2d4dafb69621809a81864c9c1b864479e1235c0dd4e199924b9742439ed89"
+dependencies = [
+ "version_check",
+]
+
[[package]]
name = "unicode-bidi"
version = "0.3.15"
diff --git a/Cargo.toml b/Cargo.toml
index c09585e6..bff10632 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -33,7 +33,7 @@ nom = "7.1.3"
notify = {version = "^6.1.1", default-features = false, features = ["macos_fsevent"]}
open = "5.1.1"
ratatui = {version = "^0.26.0", features = ["serde", "unstable-rendered-line-info"]}
-reqwest = {version = "^0.12.4", default-features = false, features = ["rustls-tls"]}
+reqwest = {version = "^0.12.4", default-features = false, features = ["multipart", "rustls-tls"]}
rmp-serde = "^1.1.2"
rusqlite = {version = "^0.31.0", default-features = false, features = ["bundled", "chrono", "uuid"]}
rusqlite_migration = "^1.2.0"
diff --git a/docs/src/api/request_collection/recipe_body.md b/docs/src/api/request_collection/recipe_body.md
index f80743bb..a52e36da 100644
--- a/docs/src/api/request_collection/recipe_body.md
+++ b/docs/src/api/request_collection/recipe_body.md
@@ -8,10 +8,11 @@ In addition, you can pass any [`Template`](./template.md) to render any text or
The following content types have first-class support. Slumber will automatically set the `Content-Type` header to the specified value, but you can override this simply by providing your own value for the header.
-| Variant | Type | `Content-Type` | Description |
-| ------------------ | -------------------------------------------- | ----------------------------------- | --------------------------------------------------------------------------------------------------------- |
-| `!json` | Any | `application/json` | Structured JSON body; all strings are treated as templates |
-| `!form_urlencoded` | [`mapping[string, Template]`](./template.md) | `application/x-www-form-urlencoded` | URL-encoded form data; [see here for more](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/POST |
+| Variant | Type | `Content-Type` | Description |
+| ------------------ | -------------------------------------------- | ----------------------------------- | ---------------------------------------------------------------------------------------------------------- |
+| `!json` | Any | `application/json` | Structured JSON body; all strings are treated as templates |
+| `!form_urlencoded` | [`mapping[string, Template]`](./template.md) | `application/x-www-form-urlencoded` | URL-encoded form data; [see here for more](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/POST) |
+| `!form_multipart` | [`mapping[string, Template]`](./template.md) | `multipart/form-data` | Binary form data; [see here for more](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/POST) |
## Examples
@@ -41,4 +42,19 @@ requests:
url: "{{host}}/fishes/{{fish_id}}"
# Content-Type header will be set automatically based on the body type
body: !json { "name": "Alfonso" }
+
+ urlencoded_body: !request
+ method: POST
+ url: "{{host}}/fishes/{{fish_id}}"
+ # Content-Type header will be set automatically based on the body type
+ body: !form_urlencoded
+ name: Alfonso
+
+ multipart_body: !request
+ method: POST
+ url: "{{host}}/fishes/{{fish_id}}"
+ # Content-Type header will be set automatically based on the body type
+ body: !form_multipart
+ name: Alfonso
+ image: "{{chains.fish_image}}"
```
diff --git a/slumber.yml b/slumber.yml
index b291262d..27258319 100644
--- a/slumber.yml
+++ b/slumber.yml
@@ -29,6 +29,9 @@ chains:
recipe: login
trigger: !expire 12h
selector: $.data
+ image:
+ source: !file
+ path: ./static/slumber.png
.ignore:
base: &base
@@ -81,6 +84,14 @@ requests:
method: GET
url: "{{host}}/image"
+ upload_image: !request
+ name: Upload Image
+ method: POST
+ url: "{{host}}/anything/image"
+ body: !form_multipart
+ filename: "logo.png"
+ image: "{{chains.image}}"
+
delay: !request
<<: *base
name: Delay
diff --git a/src/collection/cereal.rs b/src/collection/cereal.rs
index aad77095..5dbcc7fd 100644
--- a/src/collection/cereal.rs
+++ b/src/collection/cereal.rs
@@ -227,8 +227,12 @@ impl RecipeBody {
const STRUCT_NAME: &'static str = "RecipeBody";
const VARIANT_JSON: &'static str = "json";
const VARIANT_FORM_URLENCODED: &'static str = "form_urlencoded";
- const ALL_VARIANTS: &'static [&'static str] =
- &[Self::VARIANT_JSON, Self::VARIANT_FORM_URLENCODED];
+ const VARIANT_FORM_MULTIPART: &'static str = "form_multipart";
+ const ALL_VARIANTS: &'static [&'static str] = &[
+ Self::VARIANT_JSON,
+ Self::VARIANT_FORM_URLENCODED,
+ Self::VARIANT_FORM_MULTIPART,
+ ];
}
/// Custom serialization for RecipeBody, so the `Raw` variant serializes as a
@@ -255,6 +259,13 @@ impl Serialize for RecipeBody {
Self::VARIANT_FORM_URLENCODED,
value,
),
+ RecipeBody::FormMultipart(value) => serializer
+ .serialize_newtype_variant(
+ Self::STRUCT_NAME,
+ 3,
+ Self::VARIANT_FORM_MULTIPART,
+ value,
+ ),
}
}
}
@@ -316,6 +327,9 @@ impl<'de> Deserialize<'de> for RecipeBody {
RecipeBody::VARIANT_FORM_URLENCODED => {
Ok(RecipeBody::FormUrlencoded(value.newtype_variant()?))
}
+ RecipeBody::VARIANT_FORM_MULTIPART => {
+ Ok(RecipeBody::FormMultipart(value.newtype_variant()?))
+ }
other => Err(A::Error::unknown_variant(
other,
RecipeBody::ALL_VARIANTS,
@@ -519,7 +533,8 @@ mod tests {
tag: Tag::new("raw"),
value: "{{user_id}}".into()
})),
- "unknown variant `raw`, expected `json` or `form_urlencoded`",
+ "unknown variant `raw`, expected one of \
+ `json`, `form_urlencoded`, `form_multipart`",
)]
#[case::form_urlencoded_wrong_type(
serde_yaml::Value::Tagged(Box::new(TaggedValue{
diff --git a/src/collection/models.rs b/src/collection/models.rs
index 0c4dbd60..6c9c0c9a 100644
--- a/src/collection/models.rs
+++ b/src/collection/models.rs
@@ -13,7 +13,6 @@ use derive_more::{Deref, Display, From, FromStr};
use equivalent::Equivalent;
use indexmap::IndexMap;
use itertools::Itertools;
-use mime::Mime;
use serde::{Deserialize, Serialize};
use std::time::Duration;
use strum::{EnumIter, IntoEnumIterator};
@@ -296,22 +295,10 @@ pub enum RecipeBody {
Raw(Template),
/// Strutured JSON, which will be stringified and sent as text
Json(JsonBody),
- /// `application/x-www-form-urlencoded` fields
+ /// `application/x-www-form-urlencoded` fields. Values must be strings
FormUrlencoded(IndexMap),
-}
-
-impl RecipeBody {
- /// Get the MIME type of this body. For raw bodies we have no idea, but for
- /// structured bodies we can make a very educated guess
- pub fn mime(&self) -> Option {
- match self {
- RecipeBody::Raw(_) => None,
- RecipeBody::Json(_) => Some(mime::APPLICATION_JSON),
- RecipeBody::FormUrlencoded(_) => {
- Some(mime::APPLICATION_WWW_FORM_URLENCODED)
- }
- }
- }
+ /// `multipart/form-data` fields. Values can be binary
+ FormMultipart(IndexMap),
}
#[cfg(test)]
diff --git a/src/http.rs b/src/http.rs
index 49a9d207..49dbfb63 100644
--- a/src/http.rs
+++ b/src/http.rs
@@ -57,8 +57,10 @@ use futures::{
future::{self, try_join_all, OptionFuture},
Future,
};
+use mime::Mime;
use reqwest::{
header::{self, HeaderMap, HeaderName, HeaderValue},
+ multipart::{Form, Part},
Client, RequestBuilder, Response, Url,
};
use std::{collections::HashSet, sync::Arc};
@@ -228,7 +230,8 @@ impl HttpEngine {
// request
RenderedBody::Raw(bytes) => Ok(Some(bytes)),
// The body is complex - offload the hard work to RequestBuilder
- RenderedBody::FormUrlencoded(_) => {
+ RenderedBody::FormUrlencoded(_)
+ | RenderedBody::FormMultipart(_) => {
let url = Url::parse("http://localhost").unwrap();
let client = self.get_client(&url);
let mut builder = client.request(reqwest::Method::GET, url);
@@ -299,7 +302,7 @@ impl RequestTicket {
let id = self.record.id;
// Capture the rest of this method in a span
- let _ = info_span!("HTTP request", request_id = %id).entered();
+ let _ = info_span!("HTTP request", request_id = %id, ?self).entered();
// This start time will be accurate because the request doesn't launch
// until this whole future is awaited
@@ -562,6 +565,23 @@ impl Recipe {
let rendered = try_join_all(iter).await?;
RenderedBody::FormUrlencoded(rendered)
}
+ RecipeBody::FormMultipart(fields) => {
+ let iter = fields
+ .iter()
+ .enumerate()
+ // Remove disabled fields
+ .filter(|(i, _)| !options.disabled_form_fields.contains(i))
+ .map(|(_, (k, v))| async move {
+ Ok::<_, anyhow::Error>((
+ k.clone(),
+ v.render(template_context).await.context(
+ format!("Error rendering form field `{k}`"),
+ )?,
+ ))
+ });
+ let rendered = try_join_all(iter).await?;
+ RenderedBody::FormMultipart(rendered)
+ }
};
Ok(Some(rendered))
}
@@ -578,6 +598,21 @@ impl Authentication {
}
}
+impl RecipeBody {
+ /// Get the value that we should set for the `Content-Type` header,
+ /// according to the body
+ fn mime(&self) -> Option {
+ match self {
+ RecipeBody::Raw(_)
+ // Do *not* set anything for these, because reqwest will do that
+ // automatically and we don't want to interfere
+ | RecipeBody::FormUrlencoded(_)
+ | RecipeBody::FormMultipart(_) => None,
+ RecipeBody::Json(_) => Some(mime::APPLICATION_JSON),
+ }
+ }
+}
+
impl JsonBody {
/// Recursively render the JSON value. All string values will be rendered
/// as templates; other primitives remain the same.
@@ -622,8 +657,11 @@ impl JsonBody {
/// [RecipeBody]
enum RenderedBody {
Raw(Bytes),
- /// Value is `String` because only string data can be URL-encoded
+ /// Field:value mapping. Value is `String` because only string data can be
+ /// URL-encoded
FormUrlencoded(Vec<(String, String)>),
+ /// Field:value mapping. Values can be arbitrary bytes
+ FormMultipart(Vec<(String, Vec)>),
}
impl RenderedBody {
@@ -632,6 +670,14 @@ impl RenderedBody {
match self {
RenderedBody::Raw(bytes) => builder.body(bytes),
RenderedBody::FormUrlencoded(fields) => builder.form(&fields),
+ RenderedBody::FormMultipart(fields) => {
+ let mut form = Form::new();
+ for (field, value) in fields {
+ let part = Part::bytes(value);
+ form = form.part(field, part);
+ }
+ builder.multipart(form)
+ }
}
}
}
@@ -684,7 +730,7 @@ mod tests {
};
use indexmap::{indexmap, IndexMap};
use pretty_assertions::assert_eq;
- use reqwest::{Method, StatusCode};
+ use reqwest::{Body, Method, StatusCode};
use rstest::{fixture, rstest};
use serde_json::json;
@@ -750,6 +796,26 @@ mod tests {
let seed = RequestSeed::new(recipe, BuildOptions::default());
let ticket = http_engine.build(seed, &template_context).await.unwrap();
+ let expected_url: Url = "http://localhost/users/1?mode=sudo&fast=true"
+ .parse()
+ .unwrap();
+ let expected_headers = header_map([
+ ("content-type", "application/json"),
+ ("accept", "application/json"),
+ ]);
+ let expected_body = b"{\"group_id\":\"3\"}";
+
+ // Assert on the actual request
+ let request = &ticket.request;
+ assert_eq!(request.method(), Method::POST);
+ assert_eq!(request.url(), &expected_url);
+ assert_eq!(request.headers(), &expected_headers);
+ assert_eq!(
+ request.body().and_then(Body::as_bytes),
+ Some(expected_body.as_slice())
+ );
+
+ // Assert on the record too, to make sure it matches
assert_eq!(
*ticket.record,
RequestRecord {
@@ -759,14 +825,9 @@ mod tests {
),
recipe_id,
method: Method::POST,
- url: "http://localhost/users/1?mode=sudo&fast=true"
- .parse()
- .unwrap(),
- body: Some(Vec::from(b"{\"group_id\":\"3\"}").into()),
- headers: header_map([
- ("content-type", "application/json"),
- ("accept", "application/json"),
- ]),
+ url: expected_url,
+ body: Some(Vec::from(expected_body).into()),
+ headers: expected_headers,
}
);
}
@@ -907,19 +968,21 @@ mod tests {
/// Test each possible type of body. Raw bodies are covered by
/// [test_build_request]. This seems redundant with [test_build_body], but
- /// we need this to test that the `content-type` header is set correctly
+ /// we need this to test that the `content-type` header is set correctly.
+ /// This also allows us to test the actual built request, which could
+ /// hypothetically vary from the request record.
#[rstest]
#[case::json(
RecipeBody::Json(json!({"group_id": "{{group_id}}"}).into()),
None,
- br#"{"group_id":"3"}"#,
+ Some(br#"{"group_id":"3"}"#.as_slice()),
"application/json"
)]
// Content-Type has been overridden by an explicit header
#[case::json_content_type_override(
RecipeBody::Json(json!({"group_id": "{{group_id}}"}).into()),
Some("text/plain"),
- br#"{"group_id":"3"}"#,
+ Some(br#"{"group_id":"3"}"#.as_slice()),
"text/plain"
)]
#[case::form_urlencoded(
@@ -928,7 +991,7 @@ mod tests {
"token".into() => "{{token}}".into()
}),
None,
- b"user_id=1&token=hunter2",
+ Some(b"user_id=1&token=hunter2".as_slice()),
"application/x-www-form-urlencoded"
)]
// reqwest sets the content type when initializing the body, so make sure
@@ -936,16 +999,28 @@ mod tests {
#[case::form_urlencoded_content_type_override(
RecipeBody::FormUrlencoded(Default::default()),
Some("text/plain"),
- b"",
+ Some(b"".as_slice()),
"text/plain"
)]
+ #[case::form_multipart(
+ RecipeBody::FormMultipart(indexmap! {
+ "user_id".into() => "{{user_id}}".into(),
+ "binary".into() => "{{chains.binary}}".into()
+ }),
+ None,
+ // multipart bodies are automatically turned into streams by reqwest,
+ // and we don't store stream bodies atm
+ // https://github.com/LucasPickering/slumber/issues/256
+ None,
+ "multipart/form-data"
+ )]
#[tokio::test]
async fn test_structured_body(
http_engine: HttpEngine,
template_context: TemplateContext,
#[case] body: RecipeBody,
#[case] content_type: Option<&str>,
- #[case] expected_body: &'static [u8],
+ #[case] expected_body: Option<&'static [u8]>,
#[case] expected_content_type: &str,
) {
let headers = if let Some(content_type) = content_type {
@@ -963,11 +1038,26 @@ mod tests {
let seed = RequestSeed::new(recipe, BuildOptions::default());
let ticket = http_engine.build(seed, &template_context).await.unwrap();
+ // Assert on the actual built request *and* the record, to make sure
+ // they're consistent with each other
+ assert_eq!(
+ ticket
+ .request
+ .headers()
+ .get(header::CONTENT_TYPE)
+ .and_then(|value| value.to_str().ok()),
+ Some(expected_content_type)
+ );
+ assert_eq!(
+ ticket.request.body().and_then(Body::as_bytes),
+ expected_body
+ );
+
assert_eq!(
*ticket.record,
RequestRecord {
id: ticket.record.id,
- body: Some(expected_body.into()),
+ body: expected_body.map(Bytes::from),
headers: header_map([("content-type", expected_content_type)]),
..RequestRecord::factory((
Some(
diff --git a/src/http/models.rs b/src/http/models.rs
index 3626dded..331ba439 100644
--- a/src/http/models.rs
+++ b/src/http/models.rs
@@ -222,11 +222,10 @@ impl RequestRecord {
method: request.method().clone(),
url: request.url().clone(),
headers: request.headers().clone(),
- body: request.body().map(|body| {
- body.as_bytes()
- .expect("Streaming bodies not supported")
- .to_owned()
- .into()
+ body: request.body().and_then(|body| {
+ // Stream bodies are just thrown away for now
+ // https://github.com/LucasPickering/slumber/issues/256
+ Some(body.as_bytes()?.to_owned().into())
}),
}
}
diff --git a/src/tui/view/component/recipe_pane.rs b/src/tui/view/component/recipe_pane.rs
index ce4c89e5..53dc1839 100644
--- a/src/tui/view/component/recipe_pane.rs
+++ b/src/tui/view/component/recipe_pane.rs
@@ -449,6 +449,7 @@ enum RecipeBodyDisplay {
}
impl RecipeBodyDisplay {
+ /// Build a component to display the body, based on the body type
fn new(
body: &RecipeBody,
selected_profile_id: Option,
@@ -485,7 +486,8 @@ impl RecipeBodyDisplay {
.into(),
)
}
- RecipeBody::FormUrlencoded(fields) => {
+ RecipeBody::FormUrlencoded(fields)
+ | RecipeBody::FormMultipart(fields) => {
let form_items = fields
.iter()
.map(|(field, value)| {