Repository: ducaale/xh (rust)

Issue:
xh lowercases all headers
## What I expect
Xh copies case of user-specified headers.

## What actually happens
Xh treats all headers as case-insensitive and lowercases them.

## Why is this a problem?
1. It's a difference from httpie.
2. It has a negative net effect when interacting with non-compliant servers.

To explain 2...

Some services treat headers as case-sensitive (e.g with `X-*` headers, especially for auth).
The HTTP specification does specify that headers are case-insensitive, however, consider the following matrix.
If headers are treated as case insensitive, this will break servers that (incorrectly) treat them as case-sensitive, while not having a negative effect on compliant servers.
If headers are treated as case sensitive, this will work with the non-compliant servers, while not having a negative effect on compliant servers.

It would also be ok to auto-capitalize headers (e.g `foO-baR-baZ` could become `Foo-Bar-Baz`), since that's what standard industry practice anyway, but this is not what HTTPie does.

## How to test compliance
`xh --print=H localhost My-OdDlY-Capitalized-Header:Here`
compare to
`http --print=H localhost My-OdDlY-Capitalized-Header:Here`

Pull request description:
Send and display headers as title case
Resolves #166.

Patch:
diff --git a/README.md b/README.md
index 10321f2..de12ef5 100644
--- a/README.md
+++ b/README.md
@@ -162,6 +162,7 @@ xh -d httpbin.org/json -o res.json
 
 - Not all of HTTPie's features are implemented. ([#4](https://github.com/ducaale/xh/issues/4))
 - HTTP/2 cannot be disabled. ([#68](https://github.com/ducaale/xh/issues/68))
+- Header names are not case-sensitive.
 - No plugin system.
 - General immaturity. HTTPie is old and well-tested.
 - Worse documentation.
@@ -171,7 +172,6 @@ xh -d httpbin.org/json -o res.json
 - `--check-status` is enabled unless `xh` is being used in
   [strict compatibility mode](https://github.com/ducaale/xh#strict-compatibility-mode).
 - `rustls` is used instead of the system's TLS library.
-- Headers are sent and printed in lowercase.
 - JSON keys are not sorted.
 - Formatted output is always UTF-8.
 
diff --git a/src/main.rs b/src/main.rs
index 5e04027..865ec68 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -81,6 +81,7 @@ fn main() -> Result<i32> {
     };
 
     let mut client = Client::builder()
+        .http1_title_case_headers()
         .http2_adaptive_window(true)
         .timeout(timeout)
         .redirect(redirect);
diff --git a/src/printer.rs b/src/printer.rs
index 6aaf5fa..7ee21a2 100644
--- a/src/printer.rs
+++ b/src/printer.rs
@@ -7,6 +7,7 @@ use reqwest::blocking::{Request, Response};
 use reqwest::header::{
     HeaderMap, HeaderName, HeaderValue, ACCEPT, CONTENT_LENGTH, CONTENT_TYPE, HOST,
 };
+use reqwest::Version;
 use termcolor::WriteColor;
 
 use crate::{
@@ -241,15 +242,48 @@ impl Printer {
         }
     }
 
-    fn headers_to_string(&self, headers: &HeaderMap, sort: bool) -> String {
+    fn headers_to_string(&self, headers: &HeaderMap, version: Version) -> String {
+        let as_titlecase = match version {
+            Version::HTTP_09 | Version::HTTP_10 | Version::HTTP_11 => true,
+            Version::HTTP_2 | Version::HTTP_3 => false,
+            _ => false,
+        };
         let mut headers: Vec<(&HeaderName, &HeaderValue)> = headers.iter().collect();
-        if sort {
+        if self.sort_headers {
             headers.sort_by_key(|(name, _)| name.as_str());
         }
 
         let mut header_string = String::new();
         for (key, value) in headers {
-            header_string.push_str(key.as_str());
+            if as_titlecase {
+                // Ought to be equivalent to how hyper does it
+                // https://github.com/hyperium/hyper/blob/54b57c4797e1210924d901a665f9d17ae7dd9956/src/proto/h1/role.rs#L1315
+                // Header names are ASCII so it's ok to operate on char instead of u8
+                let mut iter = key.as_str().chars();
+                if let Some(c) = iter.next() {
+                    header_string.push(c.to_ascii_uppercase());
+                }
+                while let Some(c) = iter.next() {
+                    header_string.push(c);
+                    if c == '-' {
+                        if let Some(c) = iter.next() {
+                            header_string.push(c.to_ascii_uppercase());
+                        }
+                    }
+                }
+                // If https://github.com/hyperium/hyper/pull/2613 is released,
+                // switch to this implementation:
+                // let mut prev = '-';
+                // for mut c in key.as_str().chars() {
+                //     if prev == '-' {
+                //         c.make_ascii_uppercase();
+                //     }
+                //     header_string.push(c);
+                //     prev = c;
+                // }
+            } else {
+                header_string.push_str(key.as_str());
+            }
             header_string.push_str(": ");
             match value.to_str() {
                 Ok(value) => header_string.push_str(value),
@@ -299,7 +333,7 @@ impl Printer {
         }
 
         let request_line = format!("{} {}{} {:?}\n", method, url.path(), query_string, version);
-        let headers = self.headers_to_string(&headers, self.sort_headers);
+        let headers = self.headers_to_string(&headers, version);
 
         self.print_headers(&(request_line + &headers))?;
         self.buffer.print("\n\n")?;
@@ -312,7 +346,7 @@ impl Printer {
         let headers = response.headers();
 
         let status_line = format!("{:?} {}\n", version, status);
-        let headers = self.headers_to_string(headers, self.sort_headers);
+        let headers = self.headers_to_string(headers, version);
 
         self.print_headers(&(status_line + &headers))?;
         self.buffer.print("\n\n")?;
@@ -478,6 +512,8 @@ fn guess_encoding(response: &Response) -> &'static Encoding {
 
 #[cfg(test)]
 mod tests {
+    use indoc::indoc;
+
     use super::*;
     use crate::{buffer::Buffer, cli::Cli, vec_of_strings};
     use assert_matches::assert_matches;
@@ -564,4 +600,44 @@ mod tests {
         assert_eq!(p.color, true);
         assert_matches!(p.buffer, Buffer::Stderr(..));
     }
+
+    #[test]
+    fn test_header_casing() {
+        let p = Printer {
+            indent_json: false,
+            color: false,
+            theme: Theme::auto,
+            sort_headers: false,
+            stream: false,
+            buffer: Buffer::new(false, None, false, Some(Pretty::none)).unwrap(),
+        };
+        let mut headers = HeaderMap::new();
+        headers.insert("ab-cd", "0".parse().unwrap());
+        headers.insert("-cd", "0".parse().unwrap());
+        headers.insert("-", "0".parse().unwrap());
+        headers.insert("ab-%c", "0".parse().unwrap());
+        headers.insert("A-b--C", "0".parse().unwrap());
+
+        assert_eq!(
+            p.headers_to_string(&headers, reqwest::Version::HTTP_11),
+            indoc! {"
+                Ab-Cd: 0
+                -cd: 0
+                -: 0
+                Ab-%c: 0
+                A-B--c: 0"
+            }
+        );
+
+        assert_eq!(
+            p.headers_to_string(&headers, reqwest::Version::HTTP_2),
+            indoc! {"
+                ab-cd: 0
+                -cd: 0
+                -: 0
+                ab-%c: 0
+                a-b--c: 0"
+            }
+        );
+    }
 }


Test patch:
diff --git a/tests/cli.rs b/tests/cli.rs
index 1f06662..4aae461 100644
--- a/tests/cli.rs
+++ b/tests/cli.rs
@@ -117,7 +117,7 @@ fn basic_options() {
         .arg(server.base_url())
         .assert()
         .stdout(contains("HTTP/1.1 200 OK"))
-        .stdout(contains("allow:"));
+        .stdout(contains("Allow:"));
     mock.assert();
 }
 
@@ -196,13 +196,13 @@ fn verbose() {
         .assert()
         .stdout(indoc! {r#"
         POST / HTTP/1.1
-        accept: application/json, */*;q=0.5
-        accept-encoding: gzip, deflate, br
-        connection: keep-alive
-        content-length: 9
-        content-type: application/json
-        host: http.mock
-        user-agent: xh/0.0.0 (test mode)
+        Accept: application/json, */*;q=0.5
+        Accept-Encoding: gzip, deflate, br
+        Connection: keep-alive
+        Content-Length: 9
+        Content-Type: application/json
+        Host: http.mock
+        User-Agent: xh/0.0.0 (test mode)
 
         {
             "x": "y"
@@ -211,9 +211,9 @@ fn verbose() {
 
 
         HTTP/1.1 200 OK
-        content-length: 6
-        date: N/A
-        x-foo: Bar
+        Content-Length: 6
+        Date: N/A
+        X-Foo: Bar
 
         a body
         "#});