Skip to content

Commit

Permalink
Lowercase headers during serialization
Browse files Browse the repository at this point in the history
This makes lookups on the recipe header map easier. It also makes the header display in the UI match what will actually be sent, since reqwest forces lowercase anyway. It will *not* change the behavior of sent requests.
  • Loading branch information
LucasPickering committed Jan 14, 2025
1 parent 5ba1a09 commit 88e9394
Show file tree
Hide file tree
Showing 6 changed files with 79 additions and 38 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
### Changed

- Denote templates that have been edited during the current session with italics instead of a faint "(edited)" note
- Header names in recipes are now lowercased in the UI
- They have always been lowercased when the request is actually sent, so now the UI is just more representative of what will be sent

### Fixed

Expand Down
8 changes: 4 additions & 4 deletions crates/core/src/collection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -472,7 +472,7 @@ mod tests {
("fast".into(), "no_thanks".into()),
],
headers: indexmap! {
"Accept".into() => "application/json".into(),
"accept".into() => "application/json".into(),
},
}),
RecipeNode::Folder(Folder {
Expand Down Expand Up @@ -507,7 +507,7 @@ mod tests {
)),
query: vec![],
headers: indexmap! {
"Accept".into() => "application/json".into(),
"accept".into() => "application/json".into(),
},
}),
RecipeNode::Recipe(Recipe {
Expand All @@ -527,7 +527,7 @@ mod tests {
}),
query: vec![],
headers: indexmap! {
"Accept".into() => "application/json".into(),
"accept".into() => "application/json".into(),
},
}),
RecipeNode::Recipe(Recipe {
Expand All @@ -542,7 +542,7 @@ mod tests {
authentication: None,
query: vec![],
headers: indexmap! {
"Accept".into() => "application/json".into(),
"accept".into() => "application/json".into(),
},
}),
]),
Expand Down
20 changes: 20 additions & 0 deletions crates/core/src/collection/cereal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,26 @@ pub mod serde_query_parameters {
}
}

/// Deserialize a header map, lowercasing all header names. Headers are
/// case-insensitive (and must be lowercase in HTTP/2+), so forcing the case
/// makes lookups on the map easier.
pub fn deserialize_headers<'de, D>(
deserializer: D,
) -> Result<IndexMap<String, Template>, D::Error>
where
D: Deserializer<'de>,
{
// This involves an extra allocation, but it makes the logic a lot easier.
// These maps should be small anyway
let headers: IndexMap<String, Template> =
IndexMap::deserialize(deserializer)?;
Ok(headers
.into_iter()
// TODO should be ascii only?
.map(|(k, v)| (k.to_lowercase(), v))
.collect())
}

impl RecipeBody {
// Constants for serialize/deserialization. Typically these are generated
// by macros, but we need custom implementation
Expand Down
59 changes: 39 additions & 20 deletions crates/core/src/collection/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ pub struct Recipe {
pub authentication: Option<Authentication>,
#[serde(default, with = "cereal::serde_query_parameters")]
pub query: Vec<(String, Template)>,
#[serde(default)]
#[serde(default, deserialize_with = "cereal::deserialize_headers")]
pub headers: IndexMap<String, Template>,
}

Expand All @@ -202,19 +202,11 @@ impl Recipe {
/// known (e.g. JSON). Otherwise, return `None`. If the header is a
/// dynamic template, we will *not* attempt to render it, so MIME parsing
/// will fail.
/// TODO update - forms don't count
pub fn mime(&self) -> Option<Mime> {
self.headers
.get(header::CONTENT_TYPE.as_str())
.and_then(|template| template.display().parse::<Mime>().ok())
.or_else(|| {
// Use the type of the body to determine MIME
if let Some(RecipeBody::Raw { content_type, .. }) = &self.body {
content_type.as_ref().map(ContentType::to_mime)
} else {
None
}
})
.or_else(|| self.body.as_ref()?.mime())
}
}

Expand Down Expand Up @@ -609,40 +601,67 @@ impl crate::test_util::Factory for Collection {
mod tests {
use super::*;
use crate::test_util::Factory;
use indexmap::indexmap;
use rstest::rstest;

/// TODO
#[rstest]
#[case::none(None, None, None)]
#[case::header(
// Header takes precedence over body
Some("text/plain"),
Some(ContentType::Json),
Some(RecipeBody::Raw {
body: "hi!".into(),
content_type: Some(ContentType::Json),
}),
Some("text/plain")
)]
#[case::body(None, Some(ContentType::Json), Some("application/json"))]
#[case::unknown_mime(
// Fall back to body type
Some("bogus"),
Some(ContentType::Json),
Some(RecipeBody::Raw {
body: "hi!".into(),
content_type: Some(ContentType::Json),
}),
Some("application/json")
)]
#[case::json_body(
None,
Some(RecipeBody::Raw {
body: "hi!".into(),
content_type: Some(ContentType::Json),
}),
Some("application/json")
)]
#[case::unknown_body(
None,
Some(RecipeBody::Raw {
body: "hi!".into(),
content_type: None,
}),
None,
)]
#[case::form_urlencoded_body(
None,
Some(RecipeBody::FormUrlencoded(indexmap! {})),
Some("application/x-www-form-urlencoded")
)]
#[case::form_multipart_body(
None,
Some(RecipeBody::FormMultipart(indexmap! {})),
Some("multipart/form-data")
)]
fn test_recipe_mime(
#[case] header: Option<&str>,
#[case] content_type: Option<ContentType>,
#[case] body: Option<RecipeBody>,
#[case] expected: Option<&str>,
) {
let mut headers = IndexMap::new();
if let Some(header) = header {
headers.insert("content-type".into(), header.into());
}
let body = RecipeBody::Raw {
body: "body!".into(),
content_type,
};
let recipe = Recipe {
headers,
body: Some(body),
body,
..Recipe::factory(())
};
let expected = expected.and_then(|value| value.parse::<Mime>().ok());
Expand Down
14 changes: 7 additions & 7 deletions test_data/rest_http_bin.http
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ GET {{ HOST}}/get HTTP/1.1
@FULL={{ FIRST }} {{LAST}}

POST {{HOST}}/post?hello=123 HTTP/1.1
Authorization: Basic Zm9vOmJhcg==
Content-Type: application/json
authorization: Basic Zm9vOmJhcg==
content-type: application/json
X-Http-Method-Override: PUT

{
Expand All @@ -26,16 +26,16 @@ X-Http-Method-Override: PUT
@ENDPOINT = post

POST https://httpbin.org/{{ENDPOINT}} HTTP/1.1
Authorization: Bearer efaxijasdfjasdfa
Content-Type: application/x-www-form-urlencoded
My-Header: hello
Other-Header: goodbye
authorization: Bearer efaxijasdfjasdfa
content-type: application/x-www-form-urlencoded
my-header: hello
other-header: goodbye

first={{ FIRST}}&last={{LAST}}&full={{FULL}}

### Pet.json

POST {{HOST}}/post HTTP/1.1
Content-Type: application/json
content-type: application/json

< ./test_data/rest_pets.json
14 changes: 7 additions & 7 deletions test_data/rest_imported.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ profiles:
HOST: http://httpbin.org
FIRST: Joe
LAST: Smith
FULL: '{{FIRST}} {{LAST}}'
FULL: "{{FIRST}} {{LAST}}"
ENDPOINT: post
chains:
Pet_json_3_body:
Expand All @@ -21,23 +21,23 @@ requests:
SimpleGet_0: !request
name: SimpleGet
method: GET
url: '{{HOST}}/get'
url: "{{HOST}}/get"
body: null
authentication: null
query: []
headers: {}
JsonPost_1: !request
name: JsonPost
method: POST
url: '{{HOST}}/post'
url: "{{HOST}}/post"
body: !json
data: my data
name: '{{FULL}}'
name: "{{FULL}}"
authentication: !basic
username: foo
password: bar
query:
- hello=123
- hello=123
headers:
Content-Type: application/json
X-Http-Method-Override: PUT
Expand All @@ -55,8 +55,8 @@ requests:
Pet_json_3: !request
name: Pet.json
method: POST
url: '{{HOST}}/post'
body: '{{chains.Pet_json_3_body}}'
url: "{{HOST}}/post"
body: "{{chains.Pet_json_3_body}}"
authentication: null
query: []
headers:
Expand Down

0 comments on commit 88e9394

Please sign in to comment.