From eeaf1f12084a59eb21e7b5a05cc7b4596e4f75a3 Mon Sep 17 00:00:00 2001 From: Lucas Pickering Date: Sat, 8 Jun 2024 06:58:01 -0400 Subject: [PATCH 1/2] Remove need for optional persistence The request view doesn't need ExchangeBody because it will never be queryable. This removes the only need for optional persistence. Overall this is making code less generic in the name of simplicity. --- src/cli/history.rs | 5 ++- src/http.rs | 2 +- src/http/content_type.rs | 9 ----- src/http/models.rs | 15 +++---- src/tui/view/common/template_preview.rs | 7 ---- src/tui/view/component.rs | 2 +- .../{exchange_body.rs => queryable_body.rs} | 30 +++++++------- src/tui/view/component/request_view.rs | 38 ++++++++++-------- src/tui/view/component/response_view.rs | 8 ++-- src/tui/view/state/persistence.rs | 39 +++++++------------ 10 files changed, 66 insertions(+), 89 deletions(-) rename src/tui/view/component/{exchange_body.rs => queryable_body.rs} (94%) 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/http.rs b/src/http.rs index fac62d31..49a9d207 100644 --- a/src/http.rs +++ b/src/http.rs @@ -945,7 +945,7 @@ mod tests { template_context: TemplateContext, #[case] body: RecipeBody, #[case] content_type: Option<&str>, - #[case] expected_body: &[u8], + #[case] expected_body: &'static [u8], #[case] expected_content_type: &str, ) { let headers = if let Some(content_type) = content_type { 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..3626dded 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 { @@ -223,12 +223,10 @@ impl RequestRecord { 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.as_bytes() + .expect("Streaming bodies not supported") + .to_owned() + .into() }), } } @@ -264,8 +262,7 @@ impl RequestRecord { pub fn body_str(&self) -> anyhow::Result> { if let Some(body) = &self.body { Ok(Some( - std::str::from_utf8(&body.data) - .context("Error decoding body")?, + std::str::from_utf8(body).context("Error decoding body")?, )) } else { Ok(None) diff --git a/src/tui/view/common/template_preview.rs b/src/tui/view/common/template_preview.rs index 53c0584c..8a4ffb67 100644 --- a/src/tui/view/common/template_preview.rs +++ b/src/tui/view/common/template_preview.rs @@ -55,13 +55,6 @@ impl TemplatePreview { Self::Disabled { template } } } - - pub fn template(&self) -> &Template { - match self { - Self::Disabled { template } => template, - Self::Enabled { template, .. } => template, - } - } } impl Generate for &TemplatePreview { diff --git a/src/tui/view/component.rs b/src/tui/view/component.rs index c51d8531..790c3990 100644 --- a/src/tui/view/component.rs +++ b/src/tui/view/component.rs @@ -1,4 +1,3 @@ -mod exchange_body; mod exchange_pane; mod help; mod history; @@ -6,6 +5,7 @@ mod internal; mod misc; mod primary; mod profile_select; +mod queryable_body; mod recipe_list; mod recipe_pane; mod request_view; diff --git a/src/tui/view/component/exchange_body.rs b/src/tui/view/component/queryable_body.rs similarity index 94% rename from src/tui/view/component/exchange_body.rs rename to src/tui/view/component/queryable_body.rs index 699c3448..ed48219d 100644 --- a/src/tui/view/component/exchange_body.rs +++ b/src/tui/view/component/queryable_body.rs @@ -29,9 +29,10 @@ use serde_json_path::JsonPath; use std::cell::Cell; use Debug; -/// Display text body of a request OR response +/// Display response body as text, with a query box to filter it if the body has +/// been parsed #[derive(Debug)] -pub struct ExchangeBody { +pub struct QueryableBody { /// Body text content. State cell allows us to reset this whenever the /// request changes text_window: StateCell, Component>>, @@ -59,11 +60,11 @@ enum QueryCallback { Submit(String), } -impl ExchangeBody { +impl QueryableBody { /// Create a new body, optionally loading the query text from the /// persistence DB. This is optional because not all callers use the query /// box, or want to persist the value. - pub fn new(query_persistent_key: Option) -> Self { + pub fn new(query_persistent_key: PersistentKey) -> Self { let text_box = TextBox::default() .with_placeholder("'/' to filter body with JSONPath") .with_validator(|text| JsonPath::parse(text).is_ok()) @@ -84,11 +85,8 @@ impl ExchangeBody { query_available: Cell::new(false), query_focused: false, query: Default::default(), - query_text_box: Persistent::optional( - query_persistent_key, - text_box, - ) - .into(), + query_text_box: Persistent::new(query_persistent_key, text_box) + .into(), } } @@ -100,7 +98,7 @@ impl ExchangeBody { } } -impl EventHandler for ExchangeBody { +impl EventHandler for QueryableBody { fn update(&mut self, event: Event) -> Update { if let Some(Action::Search) = event.action() { if self.query_available.get() { @@ -148,7 +146,7 @@ impl EventHandler for ExchangeBody { } } -impl<'a> Draw> for ExchangeBody { +impl<'a> Draw> for QueryableBody { fn draw( &self, frame: &mut Frame, @@ -251,7 +249,9 @@ mod tests { let body = ResponseBody::new(TEXT.into()); let component = TestComponent::new( harness, - ExchangeBody::new(None), + QueryableBody::new(PersistentKey::ResponseBodyQuery( + RecipeId::factory(()), + )), ExchangeBodyProps { body: &body }, ); @@ -279,7 +279,9 @@ mod tests { ) { let mut component = TestComponent::new( harness, - ExchangeBody::new(None), + QueryableBody::new(PersistentKey::ResponseBodyQuery( + RecipeId::factory(()), + )), ExchangeBodyProps { body: &json_response.body, }, @@ -357,7 +359,7 @@ mod tests { // correctly here let component = TestComponent::new( harness, - ExchangeBody::new(Some(persistent_key)), + QueryableBody::new(persistent_key), ExchangeBodyProps { body: &json_response.body, }, diff --git a/src/tui/view/component/request_view.rs b/src/tui/view/component/request_view.rs index 276e0cfa..05a8ff21 100644 --- a/src/tui/view/component/request_view.rs +++ b/src/tui/view/component/request_view.rs @@ -4,14 +4,18 @@ use crate::{ input::Action, message::Message, view::{ - common::{actions::ActionsModal, header_table::HeaderTable}, - component::exchange_body::{ExchangeBody, ExchangeBodyProps}, + common::{ + actions::ActionsModal, + header_table::HeaderTable, + text_window::{TextWindow, TextWindowProps}, + }, draw::{Draw, DrawMetadata, Generate, ToStringGenerate}, event::{Event, EventHandler, Update}, state::StateCell, Component, ViewContext, }, }, + util::MaybeStr, }; use derive_more::Display; use ratatui::{layout::Layout, prelude::Constraint, Frame}; @@ -34,9 +38,9 @@ pub struct RequestViewProps { struct State { /// Store pointer to the request, so we can access it in the update step request: Arc, - /// Persist the request body to track view state. Update whenever the - /// loaded request changes - body: Component, + /// Persist the request body to track view state. `None` only if request + /// doesn't have a body + body: Option>>, } /// Items in the actions popup menu @@ -67,11 +71,9 @@ impl EventHandler for RequestView { // Copy exactly what the user sees. Currently requests // don't support formatting/querying but that could // change - if let Some(body) = self - .state - .get() - .and_then(|state| state.body.data().text()) - { + if let Some(body) = self.state.get().and_then(|state| { + Some(state.body.as_ref()?.data().text().clone()) + }) { ViewContext::send_message(Message::CopyText(body)); } } @@ -83,8 +85,10 @@ impl EventHandler for RequestView { } fn children(&mut self) -> Vec> { - if let Some(state) = self.state.get_mut() { - vec![state.body.as_child()] + if let Some(body) = + self.state.get_mut().and_then(|state| state.body.as_mut()) + { + vec![body.as_child()] } else { vec![] } @@ -100,7 +104,9 @@ impl Draw for RequestView { ) { let state = self.state.get_or_update(props.request.id, || State { request: Arc::clone(&props.request), - body: ExchangeBody::new(None).into(), + body: props.request.body.as_ref().map(|body| { + TextWindow::new(format!("{:#}", MaybeStr(body))).into() + }), }); let [url_area, headers_area, body_area] = Layout::vertical([ @@ -120,10 +126,8 @@ impl Draw for RequestView { .generate(), headers_area, ); - if let Some(body) = &state.request.body { - state - .body - .draw(frame, ExchangeBodyProps { body }, body_area, true); + if let Some(body) = &state.body { + body.draw(frame, TextWindowProps::default(), body_area, true); } } } diff --git a/src/tui/view/component/response_view.rs b/src/tui/view/component/response_view.rs index 8996aad9..f45c86ed 100644 --- a/src/tui/view/component/response_view.rs +++ b/src/tui/view/component/response_view.rs @@ -8,7 +8,7 @@ use crate::{ message::Message, view::{ common::{actions::ActionsModal, header_table::HeaderTable}, - component::exchange_body::{ExchangeBody, ExchangeBodyProps}, + component::queryable_body::{ExchangeBodyProps, QueryableBody}, draw::{Draw, DrawMetadata, Generate, ToStringGenerate}, event::{Event, EventHandler, Update}, state::{persistence::PersistentKey, StateCell}, @@ -55,7 +55,7 @@ struct State { /// The presentable version of the response body, which may or may not /// match the response body. We apply transformations such as filter, /// prettification, or in the case of binary responses, a hex dump. - body: Component, + body: Component, } impl EventHandler for ResponseBodyView { @@ -128,9 +128,9 @@ impl<'a> Draw> for ResponseBodyView { let response = &props.response; let state = self.state.get_or_update(props.request_id, || State { response: Arc::clone(&props.response), - body: ExchangeBody::new(Some(PersistentKey::ResponseBodyQuery( + body: QueryableBody::new(PersistentKey::ResponseBodyQuery( props.recipe_id.clone(), - ))) + )) .into(), }); diff --git a/src/tui/view/state/persistence.rs b/src/tui/view/state/persistence.rs index 98e7c699..f57aaf9b 100644 --- a/src/tui/view/state/persistence.rs +++ b/src/tui/view/state/persistence.rs @@ -20,7 +20,7 @@ use std::fmt::Debug; /// drop. #[derive(Debug, Deref, DerefMut)] pub struct Persistent { - key: Option, + key: PersistentKey, #[deref] #[deref_mut] container: T, @@ -29,22 +29,13 @@ pub struct Persistent { impl Persistent { /// Load the latest persisted value from the DB. If present, set the value /// of the container. - pub fn new(key: PersistentKey, container: T) -> Self { - Self::optional(Some(key), container) - } - - /// Create a new persistent cell, with an optional key. If the key is not - /// defined, this does not do any persistence loading/saving. This is - /// helpful for usages that should only be persistent sometimes. - pub fn optional(key: Option, mut container: T) -> Self { - if let Some(key) = &key { - // Load saved value from the database, and select it if available - let loaded = ViewContext::with_database(|database| { - database.get_ui::<_, ::Persisted>(key) - }); - if let Ok(Some(value)) = loaded { - container.set(value); - } + pub fn new(key: PersistentKey, mut container: T) -> Self { + // Load saved value from the database, and select it if available + let loaded = ViewContext::with_database(|database| { + database.get_ui::<_, ::Persisted>(&key) + }); + if let Ok(Some(value)) = loaded { + container.set(value); } Self { key, container } @@ -67,14 +58,12 @@ where impl Drop for Persistent { fn drop(&mut self) { - if let Some(key) = &self.key { - let _ = ViewContext::with_database(|database| { - database.set_ui( - key, - self.container.get().map(Persistable::get_persistent), - ) - }); - } + let _ = ViewContext::with_database(|database| { + database.set_ui( + &self.key, + self.container.get().map(Persistable::get_persistent), + ) + }); } } From 41fbfd27e51b3fecdc83e475f2d0c4137ec4baab Mon Sep 17 00:00:00 2001 From: Lucas Pickering Date: Sun, 9 Jun 2024 17:59:57 -0400 Subject: [PATCH 2/2] 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