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..0d61d1b8 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"
@@ -1547,9 +1557,9 @@ dependencies = [
[[package]]
name = "regex"
-version = "1.10.4"
+version = "1.10.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c117dbdfde9c8308975b6a18d71f3f385c89461f7b3fb054288ecf2a2058ba4c"
+checksum = "b91213439dad192326a0d7c6ee3955910425f441d7038e0d6933b0aec5c4517f"
dependencies = [
"aho-corasick",
"memchr",
@@ -1615,6 +1625,7 @@ dependencies = [
"js-sys",
"log",
"mime",
+ "mime_guess",
"once_cell",
"percent-encoding",
"pin-project-lite",
@@ -2033,6 +2044,7 @@ dependencies = [
"open",
"pretty_assertions",
"ratatui",
+ "regex",
"reqwest",
"rmp-serde",
"rstest",
@@ -2365,6 +2377,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..b013c4be 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"
@@ -52,6 +52,7 @@ uuid = {version = "^1.4.1", default-features = false, features = ["serde", "v4"]
[dev-dependencies]
mockito = {version = "1.4.0", default-features = false}
pretty_assertions = "1.4.0"
+regex = {version = "1.10.5", default-features = false}
rstest = {version = "0.19.0", default-features = false}
serde_test = "1.0.176"
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/cli/history.rs b/src/cli/history.rs
index 20f34e16..b8db2fa1 100644
--- a/src/cli/history.rs
+++ b/src/cli/history.rs
@@ -7,6 +7,7 @@ use crate::{
GlobalArgs,
};
use anyhow::anyhow;
+use bytesize::ByteSize;
use clap::Parser;
use dialoguer::console::Style;
use std::process::ExitCode;
@@ -94,8 +95,8 @@ impl HistoryCommand {
print!(
"{} ({})\n{}",
subheader_style.apply_to("Body"),
- body.size(),
- MaybeStr(body.bytes())
+ ByteSize(body.len() as u64),
+ MaybeStr(body)
)
}
println!();
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/insomnia.rs b/src/collection/insomnia.rs
index 78e79375..83437634 100644
--- a/src/collection/insomnia.rs
+++ b/src/collection/insomnia.rs
@@ -3,9 +3,9 @@
use crate::{
collection::{
- self, cereal::deserialize_from_str, Collection, Folder, JsonBody,
- Method, Profile, ProfileId, Recipe, RecipeBody, RecipeId, RecipeNode,
- RecipeTree,
+ self, cereal::deserialize_from_str, Chain, ChainId, ChainSource,
+ Collection, Folder, JsonBody, Method, Profile, ProfileId, Recipe,
+ RecipeBody, RecipeId, RecipeNode, RecipeTree,
},
template::Template,
};
@@ -16,7 +16,7 @@ use mime::Mime;
use reqwest::header;
use serde::{Deserialize, Deserializer};
use std::{collections::HashMap, fs::File, path::Path};
-use tracing::{info, warn};
+use tracing::{debug, info, warn};
impl Collection {
/// Convert an Insomnia exported collection into the slumber format. This
@@ -60,15 +60,14 @@ impl Collection {
// Convert everything we care about
let profiles = build_profiles(&workspace_id, environments);
+ let chains = build_chains(&requests);
let recipes =
build_recipe_tree(&workspace_id, request_groups, requests)?;
Ok(Collection {
profiles,
recipes,
- // Parse templates into chains:
- // https://github.com/LucasPickering/slumber/issues/164
- chains: IndexMap::new(),
+ chains,
_ignore: serde::de::IgnoredAny,
})
}
@@ -81,9 +80,14 @@ struct Insomnia {
/// Group the resources by type so they're easier to access
struct Grouped {
+ /// Root workspace ID. Useful to check which resources are top-level and
+ /// which aren't.
workspace_id: String,
+ /// Profiles
environments: Vec,
+ /// Folders
request_groups: Vec,
+ /// Recipes
requests: Vec,
}
@@ -201,11 +205,28 @@ impl Body {
}
}
+/// One parameter in a form (urlencoded or multipart)
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct FormParam {
+ id: String,
name: String,
value: String,
+ /// Variant of param. This is omitted by Insomnia for simple text params,
+ /// so we need to fill with default
+ #[serde(default, rename = "type")]
+ kind: FormParamKind,
+ /// Path of linked file, for file params only
+ file_name: Option,
+}
+
+/// The variant of a form parameter
+#[derive(Copy, Clone, Debug, Default, Deserialize)]
+#[serde(rename_all = "camelCase")]
+enum FormParamKind {
+ #[default]
+ String,
+ File,
}
impl Grouped {
@@ -364,10 +385,11 @@ impl TryFrom for RecipeBody {
RecipeBody::Json(json.map(Template::dangerous))
} else if body.mime_type == mime::APPLICATION_WWW_FORM_URLENCODED {
RecipeBody::FormUrlencoded(
- body.params
- .into_iter()
- .map(|param| (param.name, Template::dangerous(param.value)))
- .collect(),
+ body.params.into_iter().map(FormParam::into).collect(),
+ )
+ } else if body.mime_type == mime::MULTIPART_FORM_DATA {
+ RecipeBody::FormMultipart(
+ body.params.into_iter().map(FormParam::into).collect(),
)
} else {
RecipeBody::Raw(body.try_text()?)
@@ -376,6 +398,27 @@ impl TryFrom for RecipeBody {
}
}
+/// Convert an Insomnia form parameter into a corresponding map entry, to be
+/// used in a structured body
+impl From for (String, Template) {
+ fn from(param: FormParam) -> Self {
+ match param.kind {
+ // Simple string, map to a raw template
+ FormParamKind::String => {
+ (param.name, Template::dangerous(param.value))
+ }
+ // We'll map this to a chain that loads the file. The ID of the
+ // chain is the ID of this param. We're banking on that chain being
+ // created elsewhere. It's a bit spaghetti but otherwise we'd need
+ // mutable access to the entire collection, which I think would end
+ // up with even more spaghetti
+ FormParamKind::File => {
+ (param.name, Template::from_chain(¶m.id.into()))
+ }
+ }
+ }
+}
+
/// Convert authentication type. If the type is unknown, return is as `Err`
impl TryFrom for collection::Authentication {
type Error = String;
@@ -425,6 +468,7 @@ fn build_profiles(
environments
.into_iter()
.map(|environment| {
+ debug!("Generating profile for environment `{}`", environment.id);
let id: ProfileId = environment.id.into();
// Start with base data so we can overwrite it
let data = base_data
@@ -444,6 +488,52 @@ fn build_profiles(
.collect()
}
+/// Build up all the chains we need to represent the Insomnia collection.
+/// Chains don't map 1:1 with any Insomnia resource. They generally are an
+/// explicit representation of some implicit Insomnia behavior, so we have to
+/// crawl over the Insomnia collection to find where chains need to exist. For
+/// each generated chain, we'll need to pick a consistent ID so the consumer can
+/// link to the same chain.
+fn build_chains(requests: &[Request]) -> IndexMap {
+ let mut chains = IndexMap::new();
+
+ for request in requests {
+ debug!("Generating chains for request `{}`", request.id);
+
+ // Any multipart form param that references a file needs a chain
+ for param in request.body.iter().flat_map(|body| &body.params) {
+ debug!("Generating chains for form parameter `{}`", param.id);
+
+ if let FormParamKind::File = param.kind {
+ let id: ChainId = param.id.as_str().into();
+ let Some(path) = ¶m.file_name else {
+ warn!(
+ "Form param `{}` is of type `file` \
+ but missing `file_name` field",
+ param.id
+ );
+ continue;
+ };
+ chains.insert(
+ id.clone(),
+ Chain {
+ id,
+ source: ChainSource::File {
+ path: Template::dangerous(path.to_owned()),
+ },
+ sensitive: false,
+ selector: None,
+ content_type: None,
+ trim: Default::default(),
+ },
+ );
+ }
+ }
+ }
+
+ chains
+}
+
/// Expand the flat list of Insomnia resources into a recipe tree
fn build_recipe_tree(
workspace_id: &str,
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 fac62d31..60c31a2a 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,8 @@ mod tests {
};
use indexmap::{indexmap, IndexMap};
use pretty_assertions::assert_eq;
- use reqwest::{Method, StatusCode};
+ use regex::Regex;
+ use reqwest::{Body, Method, StatusCode};
use rstest::{fixture, rstest};
use serde_json::json;
@@ -750,6 +797,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 +826,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,20 +969,24 @@ 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"}"#,
- "application/json"
+ 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"}"#,
- "text/plain"
+ Some(br#"{"group_id":"3"}"#.as_slice()),
+ "^text/plain$",
+ &[],
)]
#[case::form_urlencoded(
RecipeBody::FormUrlencoded(indexmap! {
@@ -928,16 +994,31 @@ mod tests {
"token".into() => "{{token}}".into()
}),
None,
- b"user_id=1&token=hunter2",
- "application/x-www-form-urlencoded"
+ 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
// that doesn't override the user's value
#[case::form_urlencoded_content_type_override(
RecipeBody::FormUrlencoded(Default::default()),
Some("text/plain"),
- b"",
- "text/plain"
+ 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; boundary=[a-f0-9-]{67}$",
+ &[("content-length", "321")],
)]
#[tokio::test]
async fn test_structured_body(
@@ -945,8 +1026,10 @@ mod tests {
template_context: TemplateContext,
#[case] body: RecipeBody,
#[case] content_type: Option<&str>,
- #[case] expected_body: &[u8],
- #[case] expected_content_type: &str,
+ #[case] expected_body: Option<&'static [u8]>,
+ // For multipart bodies, the content type includes random content
+ #[case] expected_content_type: Regex,
+ #[case] extra_headers: &[(&str, &str)],
) {
let headers = if let Some(content_type) = content_type {
indexmap! {"content-type".into() => content_type.into()}
@@ -963,12 +1046,38 @@ 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
+ let actual_content_type = ticket
+ .request
+ .headers()
+ .get(header::CONTENT_TYPE)
+ .expect("Missing Content-Type header")
+ .to_str()
+ .expect("Invalid Content-Type header");
+ assert!(
+ expected_content_type.is_match(actual_content_type),
+ "Expected Content-Type `{actual_content_type}` \
+ to match `{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()),
- headers: header_map([("content-type", expected_content_type)]),
+ body: expected_body.map(Bytes::from),
+ // Use the actual content type here, because the expected
+ // content type maybe be a pattern and we need an exactl string.
+ // We checked actual=expected above so this is fine
+ headers: header_map(
+ [("content-type", actual_content_type)]
+ .into_iter()
+ .chain(extra_headers.iter().copied())
+ ),
..RequestRecord::factory((
Some(
template_context.collection.first_profile_id().clone()
diff --git a/src/http/content_type.rs b/src/http/content_type.rs
index eb4feaf0..2ad533c0 100644
--- a/src/http/content_type.rs
+++ b/src/http/content_type.rs
@@ -35,15 +35,6 @@ impl ContentType {
const EXTENSIONS: Mapping<'static, ContentType> =
Mapping::new(&[(Self::Json, &["json"])]);
- /// Get MIME corresponding to this content type. Each content type maps to a
- /// single MIME (although the reverse is not true)
- pub fn mime(&self) -> Mime {
- // Don't use a Mapping because this is a one-way relationship
- match self {
- ContentType::Json => mime::APPLICATION_JSON,
- }
- }
-
/// Parse the value of the content-type header and map it to a known content
/// type
fn from_mime(mime_type: &str) -> anyhow::Result {
diff --git a/src/http/models.rs b/src/http/models.rs
index f5027b88..331ba439 100644
--- a/src/http/models.rs
+++ b/src/http/models.rs
@@ -196,7 +196,7 @@ pub struct RequestRecord {
#[serde(with = "cereal::serde_header_map")]
pub headers: HeaderMap,
/// Body content as bytes. This should be decoded as needed
- pub body: Option,
+ pub body: Option,
}
impl RequestRecord {
@@ -222,13 +222,10 @@ impl RequestRecord {
method: request.method().clone(),
url: request.url().clone(),
headers: request.headers().clone(),
- body: request.body().map(|body| {
- ResponseBody::new(
- 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())
}),
}
}
@@ -264,8 +261,7 @@ impl RequestRecord {
pub fn body_str(&self) -> anyhow::Result