From fc30c64a03f1f20b6bbfb8a4f4e0939e6040365b Mon Sep 17 00:00:00 2001 From: Lucas Pickering Date: Sun, 9 Jun 2024 17:59:57 -0400 Subject: [PATCH] Add multipart/form-data support Add a new structured body type !form_multipart, which allows sending forms with binary data. This is a bit incomplete, we need to support streaming bodies in https://github.com/LucasPickering/slumber/issues/256. Closes #243 --- CHANGELOG.md | 1 + Cargo.lock | 25 ++- Cargo.toml | 3 +- .../src/api/request_collection/recipe_body.md | 24 ++- slumber.yml | 11 ++ src/collection/cereal.rs | 21 ++- src/collection/insomnia.rs | 112 ++++++++++-- src/collection/models.rs | 19 +-- src/http.rs | 159 +++++++++++++++--- src/http/models.rs | 9 +- src/template/parse.rs | 27 ++- src/tui/view/component/recipe_pane.rs | 4 +- test_data/insomnia.json | 144 +++++++++++++--- test_data/insomnia_imported.yml | 27 ++- 14 files changed, 495 insertions(+), 91 deletions(-) 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/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 49a9d207..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: &'static [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/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/template/parse.rs b/src/template/parse.rs index 698d4f97..4be21a75 100644 --- a/src/template/parse.rs +++ b/src/template/parse.rs @@ -1,6 +1,9 @@ //! Template string parser -use crate::template::{error::TemplateParseError, Template, TemplateKey}; +use crate::{ + collection::ChainId, + template::{error::TemplateParseError, Template, TemplateKey}, +}; use nom::{ branch::alt, bytes::complete::{tag, take_while1}, @@ -35,6 +38,17 @@ impl Template { Ok(Self { template, chunks }) } + + /// Create a template that renders a single chain. This creates a template + /// equivalent to `{{chains.}}` + pub fn from_chain(id: &ChainId) -> Self { + // We need to double up all our own curly braces to escape them. + // Technically we could construct the template directly, but it's a lot + // more robust to re-use the parsing logic, since we need to build up + // the template string anyway + Template::parse(format!("{{{{{CHAIN_PREFIX}{id}}}}}")) + .expect("Generated template is invalid") + } } /// A parsed piece of a template. After parsing, each chunk is either raw text @@ -210,4 +224,15 @@ mod tests { fn test_parse_error(#[case] template: &str) { assert_err!(Template::parse(template.into()), "at line 1"); } + + /// Test that [Template::from_chain] generates the correct template + #[test] + fn test_from_chain() { + let template = Template::from_chain(&"chain1".into()); + assert_eq!(&template.template, "{{chains.chain1}}"); + assert_eq!( + &template.chunks, + &[TemplateInputChunk::Key(TemplateKey::Chain(Span::new(9, 6)))] + ); + } } 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)| { diff --git a/test_data/insomnia.json b/test_data/insomnia.json index 2c91e0cf..705e0f04 100644 --- a/test_data/insomnia.json +++ b/test_data/insomnia.json @@ -1,7 +1,7 @@ { "_type": "export", "__export_format": 4, - "__export_date": "2024-06-04T21:09:14.812Z", + "__export_date": "2024-06-09T20:02:13.394Z", "__export_source": "insomnia.desktop.app:v9.2.0", "resources": [ { @@ -37,7 +37,10 @@ "name": "Content-Type", "value": "application/x-www-form-urlencoded" }, - { "name": "User-Agent", "value": "insomnia/8.6.1" } + { + "name": "User-Agent", + "value": "insomnia/8.6.1" + } ], "authentication": {}, "metaSortKey": -1712668704594, @@ -70,12 +73,21 @@ "name": "With Text Body", "description": "", "method": "POST", - "body": { "mimeType": "text/plain", "text": "hello!" }, + "body": { + "mimeType": "text/plain", + "text": "hello!" + }, "preRequestScript": "", "parameters": [], "headers": [ - { "name": "Content-Type", "value": "text/plain" }, - { "name": "User-Agent", "value": "insomnia/8.6.1" } + { + "name": "Content-Type", + "value": "text/plain" + }, + { + "name": "User-Agent", + "value": "insomnia/8.6.1" + } ], "authentication": {}, "metaSortKey": -1712668712522, @@ -117,8 +129,14 @@ "preRequestScript": "", "parameters": [], "headers": [ - { "name": "Content-Type", "value": "application/json" }, - { "name": "User-Agent", "value": "insomnia/8.6.1" } + { + "name": "Content-Type", + "value": "application/json" + }, + { + "name": "User-Agent", + "value": "insomnia/8.6.1" + } ], "authentication": {}, "metaSortKey": -1712668712422, @@ -132,6 +150,58 @@ "settingFollowRedirects": "global", "_type": "request" }, + { + "_id": "req_a01b6de924274654bda0835e2a073bd0", + "parentId": "fld_9a7332db608943b093c929a82c81df50", + "modified": 1717963268333, + "created": 1717550289566, + "url": "https://httpbin.org/post", + "name": "With Multipart Body", + "description": "", + "method": "POST", + "body": { + "mimeType": "multipart/form-data", + "params": [ + { + "id": "pair_92d49a8597ea457aa377169046e6670c", + "name": "username", + "value": "user", + "description": "" + }, + { + "id": "pair_b9dfab38415a4c98a08d99a1d4a35682", + "name": "image", + "value": "", + "description": "", + "type": "file", + "fileName": "./public/slumber.png" + } + ] + }, + "preRequestScript": "", + "parameters": [], + "headers": [ + { + "name": "Content-Type", + "value": "multipart/form-data" + }, + { + "name": "User-Agent", + "value": "insomnia/8.6.1" + } + ], + "authentication": {}, + "metaSortKey": -1712668712372, + "isPrivate": false, + "pathParameters": [], + "settingStoreCookies": true, + "settingSendCookies": true, + "settingDisableRenderRequestBody": false, + "settingEncodeUrl": true, + "settingRebuildPath": true, + "settingFollowRedirects": "global", + "_type": "request" + }, { "_id": "req_2ec3dc9ff6774ac78248777e75984831", "parentId": "fld_8077c48f5a89436bbe4b3a53c06471f5", @@ -144,7 +214,12 @@ "body": {}, "preRequestScript": "", "parameters": [], - "headers": [{ "name": "User-Agent", "value": "insomnia/8.6.1" }], + "headers": [ + { + "name": "User-Agent", + "value": "insomnia/8.6.1" + } + ], "authentication": { "type": "bearer", "token": " {% response 'body', 'req_3bc2de939f1a4d1ebc00835cbefd6b5d', 'b64::JC5oZWFkZXJzLkhvc3Q=::46b', 'when-expired', 60 %}" @@ -184,7 +259,12 @@ "body": {}, "preRequestScript": "", "parameters": [], - "headers": [{ "name": "User-Agent", "value": "insomnia/8.6.1" }], + "headers": [ + { + "name": "User-Agent", + "value": "insomnia/8.6.1" + } + ], "authentication": { "type": "digest", "disabled": false, @@ -214,7 +294,12 @@ "body": {}, "preRequestScript": "", "parameters": [], - "headers": [{ "name": "User-Agent", "value": "insomnia/8.6.1" }], + "headers": [ + { + "name": "User-Agent", + "value": "insomnia/8.6.1" + } + ], "authentication": { "type": "basic", "useISO88591": false, @@ -245,7 +330,12 @@ "body": {}, "preRequestScript": "", "parameters": [], - "headers": [{ "name": "User-Agent", "value": "insomnia/8.6.1" }], + "headers": [ + { + "name": "User-Agent", + "value": "insomnia/8.6.1" + } + ], "authentication": {}, "metaSortKey": -1712668873967, "isPrivate": false, @@ -261,11 +351,17 @@ { "_id": "env_99d30891da4bdcebc63947a8fc17f076de878684", "parentId": "wrk_scratchpad", - "modified": 1717286296579, + "modified": 1717963323879, "created": 1717286296579, "name": "Base Environment", - "data": {}, - "dataPropertyOrder": null, + "data": { + "base_field": "base" + }, + "dataPropertyOrder": { + "&": [ + "base_field" + ] + }, "color": null, "isPrivate": false, "metaSortKey": 1717286296579, @@ -283,7 +379,7 @@ { "_id": "env_3b607180e18c41228387930058c9ca43", "parentId": "env_99d30891da4bdcebc63947a8fc17f076de878684", - "modified": 1710624708900, + "modified": 1717963225790, "created": 1710624684621, "name": "Local", "data": { @@ -291,9 +387,11 @@ "greeting": "hello!" }, "dataPropertyOrder": { - "&": ["host", "greeting"] + "&": [ + "host", + "greeting" + ] }, - "dataPropertyOrder": { "&": ["host"] }, "color": null, "isPrivate": false, "metaSortKey": 1710624684621, @@ -305,8 +403,16 @@ "modified": 1713480842495, "created": 1710624689589, "name": "Remote", - "data": { "host": "https://httpbin.org", "greeting": "howdy" }, - "dataPropertyOrder": { "&": ["host", "greeting"] }, + "data": { + "host": "https://httpbin.org", + "greeting": "howdy" + }, + "dataPropertyOrder": { + "&": [ + "host", + "greeting" + ] + }, "color": null, "isPrivate": false, "metaSortKey": 1710624689589, diff --git a/test_data/insomnia_imported.yml b/test_data/insomnia_imported.yml index b654464a..8bf2ed3e 100644 --- a/test_data/insomnia_imported.yml +++ b/test_data/insomnia_imported.yml @@ -3,14 +3,21 @@ profiles: env_3b607180e18c41228387930058c9ca43: name: Local data: + base_field: base host: http://localhost:3000 greeting: hello! env_4fb19173966e42898a0a77f45af591c9: name: Remote data: + base_field: base host: https://httpbin.org greeting: howdy -chains: {} + +chains: + pair_b9dfab38415a4c98a08d99a1d4a35682: + source: !file + path: ./public/slumber.png + requests: fld_9a7332db608943b093c929a82c81df50: !folder name: My Folder @@ -60,23 +67,37 @@ requests: name: With Text Body method: POST url: https://httpbin.org/post - body: "hello!" authentication: null query: {} headers: content-type: text/plain + body: "hello!" req_1419670d20eb4964956df954e1eb7c4b: !request name: With JSON Body method: POST url: https://httpbin.org/post - body: !json { "message": "hello!" } authentication: null query: {} headers: # This is redundant with the body type, but it's more effort than # it's worth to remove it content-type: application/json + body: !json { "message": "hello!" } + + req_a01b6de924274654bda0835e2a073bd0: !request + name: With Multipart Body + method: POST + url: https://httpbin.org/post + authentication: null + query: {} + headers: + # This is redundant with the body type, but it's more effort than + # it's worth to remove it + content-type: multipart/form-data + body: !form_multipart + username: user + image: "{{chains.pair_b9dfab38415a4c98a08d99a1d4a35682}}" req_a345faa530a7453e83ee967d18555712: !request name: Login