Skip to content

Commit

Permalink
Add support for URL-encoded forms
Browse files Browse the repository at this point in the history
Closes #244
  • Loading branch information
LucasPickering committed Jun 5, 2024
1 parent da77fd2 commit 3846d13
Show file tree
Hide file tree
Showing 15 changed files with 473 additions and 240 deletions.
7 changes: 4 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
7 changes: 4 additions & 3 deletions docs/src/api/request_collection/recipe_body.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 3 additions & 2 deletions slumber.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions src/collection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
},
}),
]),
}),
])
Expand Down
68 changes: 56 additions & 12 deletions src/collection/cereal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down Expand Up @@ -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
Expand All @@ -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,
),
}
}
}
Expand Down Expand Up @@ -235,9 +244,12 @@ impl<'de> Deserialize<'de> for RecipeBody {
{
let (tag, value) = data.variant::<String>()?;
match tag.as_str() {
RecipeBody::VARIANT_JSON => Ok(RecipeBody::Json(
value.newtype_variant::<JsonBody>()?,
)),
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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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::<Mapping>().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<serde_yaml::Value>,
Expand Down Expand Up @@ -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<serde_yaml::Value>,
Expand Down Expand Up @@ -501,4 +532,17 @@ mod tests {
) {
assert_de_tokens_error::<WrapDuration>(&[Token::Str(s)], error)
}

/// Build a YAML mapping
fn mapping(
fields: impl IntoIterator<Item = (&'static str, &'static str)>,
) -> serde_yaml::Value {
fields
.into_iter()
.map(|(k, v)| {
(serde_yaml::Value::from(k), serde_yaml::Value::from(v))
})
.collect::<Mapping>()
.into()
}
}
36 changes: 30 additions & 6 deletions src/collection/insomnia.rs
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,23 @@ struct Body {
/// This field is only present for text-like bodies (e.g. *not* forms)
#[serde(default)]
text: Option<Template>,
/// Present for form-like bodies
#[serde(default)]
params: Vec<FormParam>,
}

impl Body {
fn try_text(self) -> anyhow::Result<Template> {
self.text
.ok_or_else(|| anyhow!("Body missing `text` field"))
}
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct FormParam {
name: String,
value: String,
}

impl Grouped {
Expand Down Expand Up @@ -335,18 +352,25 @@ impl TryFrom<Body> for RecipeBody {
type Error = anyhow::Error;

fn try_from(body: Body) -> anyhow::Result<Self> {
let text = body.text.ok_or_else(|| anyhow!("Missing `text` field"))?;

let body = if body.mime_type == mime::APPLICATION_JSON {
// Parse JSON to our own JSON equivalent
let json: JsonBody<String> =
serde_json::from_str::<serde_json::Value>(text.as_str())
.context("Error parsing body as JSON")?
.into();
serde_json::from_str::<serde_json::Value>(
body.try_text()?.as_str(),
)
.context("Error parsing body as JSON")?
.into();
// Convert each string into a template *without* parsing
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(),
)
} else {
RecipeBody::Raw(text)
RecipeBody::Raw(body.try_text()?)
};
Ok(body)
}
Expand Down
16 changes: 12 additions & 4 deletions src/collection/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ 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};
Expand Down Expand Up @@ -288,17 +289,24 @@ pub enum Authentication<T = Template> {
#[derive(Clone, Debug)]
#[cfg_attr(test, derive(PartialEq))]
pub enum RecipeBody {
/// Plain string/bytes body
Raw(Template),
/// Strutured JSON, which will be stringified and sent as text
Json(JsonBody),
/// `application/x-www-form-urlencoded` fields
FormUrlencoded(IndexMap<String, Template>),
}

impl RecipeBody {
/// For structured bodies, get the corresponding [ContentType]. Return
/// `None` for raw bodies
pub fn content_type(&self) -> Option<ContentType> {
/// 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<Mime> {
match self {
RecipeBody::Raw(_) => None,
RecipeBody::Json(_) => Some(ContentType::Json),
RecipeBody::Json(_) => Some(mime::APPLICATION_JSON),
RecipeBody::FormUrlencoded(_) => {
Some(mime::APPLICATION_WWW_FORM_URLENCODED)
}
}
}
}
Expand Down
Loading

0 comments on commit 3846d13

Please sign in to comment.