diff --git a/CHANGELOG.md b/CHANGELOG.md index 971141b7..8a359225 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/crates/core/src/collection.rs b/crates/core/src/collection.rs index 31b69a4b..07276e3a 100644 --- a/crates/core/src/collection.rs +++ b/crates/core/src/collection.rs @@ -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 { @@ -507,7 +507,7 @@ mod tests { )), query: vec![], headers: indexmap! { - "Accept".into() => "application/json".into(), + "accept".into() => "application/json".into(), }, }), RecipeNode::Recipe(Recipe { @@ -527,7 +527,7 @@ mod tests { }), query: vec![], headers: indexmap! { - "Accept".into() => "application/json".into(), + "accept".into() => "application/json".into(), }, }), RecipeNode::Recipe(Recipe { @@ -542,7 +542,7 @@ mod tests { authentication: None, query: vec![], headers: indexmap! { - "Accept".into() => "application/json".into(), + "accept".into() => "application/json".into(), }, }), ]), diff --git a/crates/core/src/collection/cereal.rs b/crates/core/src/collection/cereal.rs index d7ed6151..b1ec26a5 100644 --- a/crates/core/src/collection/cereal.rs +++ b/crates/core/src/collection/cereal.rs @@ -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, 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 = + 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 diff --git a/crates/core/src/collection/models.rs b/crates/core/src/collection/models.rs index 34b1e267..20d852d9 100644 --- a/crates/core/src/collection/models.rs +++ b/crates/core/src/collection/models.rs @@ -186,7 +186,7 @@ pub struct Recipe { pub authentication: Option, #[serde(default, with = "cereal::serde_query_parameters")] pub query: Vec<(String, Template)>, - #[serde(default)] + #[serde(default, deserialize_with = "cereal::deserialize_headers")] pub headers: IndexMap, } @@ -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 { self.headers .get(header::CONTENT_TYPE.as_str()) .and_then(|template| template.display().parse::().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()) } } @@ -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, + #[case] body: Option, #[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::().ok()); diff --git a/test_data/rest_http_bin.http b/test_data/rest_http_bin.http index 7c77afc4..df9516db 100644 --- a/test_data/rest_http_bin.http +++ b/test_data/rest_http_bin.http @@ -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 { @@ -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 diff --git a/test_data/rest_imported.yml b/test_data/rest_imported.yml index 94bca69b..40f15698 100644 --- a/test_data/rest_imported.yml +++ b/test_data/rest_imported.yml @@ -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: @@ -21,7 +21,7 @@ requests: SimpleGet_0: !request name: SimpleGet method: GET - url: '{{HOST}}/get' + url: "{{HOST}}/get" body: null authentication: null query: [] @@ -29,15 +29,15 @@ requests: 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 @@ -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: