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 4, 2024
1 parent da77fd2 commit fedf262
Show file tree
Hide file tree
Showing 9 changed files with 394 additions and 167 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
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
66 changes: 55 additions & 11 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 @@ -431,6 +455,13 @@ mod tests {
})),
"unknown variant `raw`, expected `json`",
)]
#[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>,
#[case] expected_error: &str,
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()
}
}
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 fedf262

Please sign in to comment.