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)| {