From 3846d1379a496b68e65a502a84dd617b8a434ecc Mon Sep 17 00:00:00 2001 From: Lucas Pickering Date: Tue, 4 Jun 2024 17:20:56 -0400 Subject: [PATCH] Add support for URL-encoded forms Closes #244 --- CHANGELOG.md | 7 +- README.md | 5 + .../src/api/request_collection/recipe_body.md | 7 +- slumber.yml | 5 +- src/collection.rs | 15 + src/collection/cereal.rs | 68 ++++- src/collection/insomnia.rs | 36 ++- src/collection/models.rs | 16 +- src/http.rs | 260 ++++++++++++------ src/http/models.rs | 22 +- src/tui/view/component/recipe_pane.rs | 170 +++++++++--- src/tui/view/state/persistence.rs | 8 + test_data/insomnia.json | 82 ++---- test_data/insomnia_imported.yml | 4 +- test_data/regression.yml | 8 + 15 files changed, 473 insertions(+), 240 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e83962da..0b030a1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,9 +8,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Added -- JSON bodies can now be defined with the `!json` tag [#242](https://github.com/LucasPickering/slumber/issues/242) - - This should make JSON requests more convenient to write, because you no longer have to specify the `Content-Type` header yourself - - [See docs](https://slumber.lucaspickering.me/book/api/request_collection/recipe_body.html) +- 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) + - [See docs](https://slumber.lucaspickering.me/book/api/request_collection/recipe_body.html) for usage instructions - Templates can now render binary values in certain contexts - [See docs](https://slumber.lucaspickering.me/book/user_guide/templates.html#binary-templates) diff --git a/README.md b/README.md index 204d635c..83fb5966 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,11 @@ requests: get: !request method: GET url: https://httpbin.org/get + + post: !request + method: POST + url: https://httpbin.org/post + body: !json { "id": 3, "name": "Slumber" } ``` Create this file, then run the TUI with `slumber`. diff --git a/docs/src/api/request_collection/recipe_body.md b/docs/src/api/request_collection/recipe_body.md index 61db4b5e..f80743bb 100644 --- a/docs/src/api/request_collection/recipe_body.md +++ b/docs/src/api/request_collection/recipe_body.md @@ -8,9 +8,10 @@ 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` Header | Description | -| ------- | ---- | --------------------- | ---------------------------------------------------------------- | -| `!json` | Any | `application/json` | Structured JSON body, where all strings are treated as templates | +| 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 | ## Examples diff --git a/slumber.yml b/slumber.yml index 2718091c..b1fa56e3 100644 --- a/slumber.yml +++ b/slumber.yml @@ -45,8 +45,9 @@ requests: fast: no_thanks headers: Accept: application/json - body: - !json { "username": "{{username}}", "password": "{{chains.password}}" } + body: !form_urlencoded + username: "{{username}}" + password: "{{chains.password}}" users: !folder name: Users diff --git a/src/collection.rs b/src/collection.rs index 8649696a..fbb87010 100644 --- a/src/collection.rs +++ b/src/collection.rs @@ -523,6 +523,21 @@ mod tests { "Accept".into() => "application/json".into(), }, }), + RecipeNode::Recipe(Recipe { + id: "form_urlencoded_body".into(), + name: Some("Modify User".into()), + method: Method::Put, + url: "{{host}}/anything/{{user_guid}}".into(), + + body: Some(RecipeBody::FormUrlencoded(indexmap! { + "username".into() => "new username".into() + })), + authentication: None, + query: indexmap! {}, + headers: indexmap! { + "Accept".into() => "application/json".into(), + }, + }), ]), }), ]) diff --git a/src/collection/cereal.rs b/src/collection/cereal.rs index d5498a6a..a29610ff 100644 --- a/src/collection/cereal.rs +++ b/src/collection/cereal.rs @@ -2,8 +2,8 @@ use crate::{ collection::{ - recipe_tree::RecipeNode, Chain, ChainId, JsonBody, Profile, ProfileId, - Recipe, RecipeBody, RecipeId, + recipe_tree::RecipeNode, Chain, ChainId, Profile, ProfileId, Recipe, + RecipeBody, RecipeId, }, template::Template, }; @@ -160,7 +160,9 @@ impl RecipeBody { // by macros, but we need custom implementation const STRUCT_NAME: &'static str = "RecipeBody"; const VARIANT_JSON: &'static str = "json"; - const ALL_VARIANTS: &'static [&'static str] = &[Self::VARIANT_JSON]; + const VARIANT_FORM_URLENCODED: &'static str = "form_urlencoded"; + const ALL_VARIANTS: &'static [&'static str] = + &[Self::VARIANT_JSON, Self::VARIANT_FORM_URLENCODED]; } /// Custom serialization for RecipeBody, so the `Raw` variant serializes as a @@ -180,6 +182,13 @@ impl Serialize for RecipeBody { Self::VARIANT_JSON, value, ), + RecipeBody::FormUrlencoded(value) => serializer + .serialize_newtype_variant( + Self::STRUCT_NAME, + 2, + Self::VARIANT_FORM_URLENCODED, + value, + ), } } } @@ -235,9 +244,12 @@ impl<'de> Deserialize<'de> for RecipeBody { { let (tag, value) = data.variant::()?; match tag.as_str() { - RecipeBody::VARIANT_JSON => Ok(RecipeBody::Json( - value.newtype_variant::()?, - )), + RecipeBody::VARIANT_JSON => { + Ok(RecipeBody::Json(value.newtype_variant()?)) + } + RecipeBody::VARIANT_FORM_URLENCODED => { + Ok(RecipeBody::FormUrlencoded(value.newtype_variant()?)) + } other => Err(A::Error::unknown_variant( other, RecipeBody::ALL_VARIANTS, @@ -340,6 +352,7 @@ pub mod serde_duration { mod tests { use super::*; use crate::test_util::assert_err; + use indexmap::indexmap; use rstest::rstest; use serde::Serialize; use serde_json::json; @@ -380,20 +393,31 @@ mod tests { )] #[case::json( RecipeBody::Json(json!({"user": "{{user_id}}"}).into()), - serde_yaml::Value::Tagged(Box::new(TaggedValue{ + serde_yaml::Value::Tagged(Box::new(TaggedValue { tag: Tag::new("json"), - value: [ - (serde_yaml::Value::from("user"), "{{user_id}}".into()) - ].into_iter().collect::().into() + value: mapping([("user", "{{user_id}}")]) })), )] #[case::json_nested( RecipeBody::Json(json!(r#"{"warning": "NOT an object"}"#).into()), - serde_yaml::Value::Tagged(Box::new(TaggedValue{ + serde_yaml::Value::Tagged(Box::new(TaggedValue { tag: Tag::new("json"), value: r#"{"warning": "NOT an object"}"#.into() })), )] + #[case::form_urlencoded( + RecipeBody::FormUrlencoded(indexmap! { + "username".into() => "{{username}}".into(), + "password".into() => "{{chains.password}}".into(), + }), + serde_yaml::Value::Tagged(Box::new(TaggedValue { + tag: Tag::new("form_urlencoded"), + value: mapping([ + ("username", "{{username}}"), + ("password", "{{chains.password}}"), + ]) + })) + )] fn test_serde_recipe_body( #[case] body: RecipeBody, #[case] yaml: impl Into, @@ -429,7 +453,14 @@ mod tests { tag: Tag::new("raw"), value: "{{user_id}}".into() })), - "unknown variant `raw`, expected `json`", + "unknown variant `raw`, expected `json` or `form_urlencoded`", + )] + #[case::form_urlencoded_wrong_type( + serde_yaml::Value::Tagged(Box::new(TaggedValue{ + tag: Tag::new("form_urlencoded"), + value: "{{user_id}}".into() + })), + "invalid type: string \"{{user_id}}\", expected a map" )] fn test_deserialize_recipe_error( #[case] yaml: impl Into, @@ -501,4 +532,17 @@ mod tests { ) { assert_de_tokens_error::(&[Token::Str(s)], error) } + + /// Build a YAML mapping + fn mapping( + fields: impl IntoIterator, + ) -> serde_yaml::Value { + fields + .into_iter() + .map(|(k, v)| { + (serde_yaml::Value::from(k), serde_yaml::Value::from(v)) + }) + .collect::() + .into() + } } diff --git a/src/collection/insomnia.rs b/src/collection/insomnia.rs index f9e190c0..78e79375 100644 --- a/src/collection/insomnia.rs +++ b/src/collection/insomnia.rs @@ -189,6 +189,23 @@ struct Body { /// This field is only present for text-like bodies (e.g. *not* forms) #[serde(default)] text: Option