Skip to content

Commit

Permalink
Introduces HeaderVec struct to represent a list of HTTP headers.
Browse files Browse the repository at this point in the history
  • Loading branch information
jcamiel committed Feb 9, 2024
1 parent b543c4b commit 6d77de1
Show file tree
Hide file tree
Showing 9 changed files with 182 additions and 64 deletions.
31 changes: 15 additions & 16 deletions packages/hurl/src/http/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ use url::Url;

use crate::http::certificate::Certificate;
use crate::http::core::*;
use crate::http::header::{
HeaderVec, ACCEPT_ENCODING, AUTHORIZATION, CONTENT_TYPE, EXPECT, USER_AGENT,
};
use crate::http::options::ClientOptions;
use crate::http::request::*;
use crate::http::request_spec::*;
Expand Down Expand Up @@ -164,7 +167,7 @@ impl Client {
let start = Utc::now();
let verbose = options.verbosity.is_some();
let very_verbose = options.verbosity == Some(Verbosity::VeryVerbose);
let mut request_headers: Vec<Header> = vec![];
let mut request_headers = HeaderVec::new();
let mut status_lines = vec![];
let mut response_headers = vec![];
let has_body_data = !request_spec.body.bytes().is_empty()
Expand Down Expand Up @@ -523,34 +526,34 @@ impl Client {
}

// If request has no Content-Type header, we set it if the content type has been set explicitly on this request.
if request.get_header_values(Header::CONTENT_TYPE).is_empty() {
if request.get_header_values(CONTENT_TYPE).is_empty() {
if let Some(s) = &request.content_type {
list.append(&format!("{}: {s}", Header::CONTENT_TYPE))?;
list.append(&format!("{}: {s}", CONTENT_TYPE))?;
} else {
// We remove default Content-Type headers added by curl because we want
// to explicitly manage this header.
// For instance, with --data option, curl will send a 'Content-type: application/x-www-form-urlencoded'
// header.
list.append(&format!("{}:", Header::CONTENT_TYPE))?;
list.append(&format!("{}:", CONTENT_TYPE))?;
}
}

// Workaround for libcurl issue #11664: When hurl explicitly sets `Expect:` to remove the header,
// libcurl will generate `SignedHeaders` that include `expect` even though the header is not
// present, causing some APIs to reject the request.
// Therefore we only remove this header when not in aws_sigv4 mode.
if request.get_header_values(Header::EXPECT).is_empty() && options.aws_sigv4.is_none() {
if request.get_header_values(EXPECT).is_empty() && options.aws_sigv4.is_none() {
// We remove default Expect headers added by curl because we want
// to explicitly manage this header.
list.append(&format!("{}:", Header::EXPECT))?;
list.append(&format!("{}:", EXPECT))?;
}

if request.get_header_values(Header::USER_AGENT).is_empty() {
if request.get_header_values(USER_AGENT).is_empty() {
let user_agent = match options.user_agent {
Some(ref u) => u.clone(),
None => format!("hurl/{}", clap::crate_version!()),
};
list.append(&format!("{}: {user_agent}", Header::USER_AGENT))?;
list.append(&format!("{}: {user_agent}", USER_AGENT))?;
}

if let Some(ref user) = options.user {
Expand All @@ -565,17 +568,13 @@ impl Client {
} else {
let user = user.as_bytes();
let authorization = general_purpose::STANDARD.encode(user);
if request.get_header_values(Header::AUTHORIZATION).is_empty() {
list.append(&format!("{}: Basic {authorization}", Header::AUTHORIZATION))?;
if request.get_header_values(AUTHORIZATION).is_empty() {
list.append(&format!("{}: Basic {authorization}", AUTHORIZATION))?;
}
}
}
if options.compressed
&& request
.get_header_values(Header::ACCEPT_ENCODING)
.is_empty()
{
list.append(&format!("{}: gzip, deflate, br", Header::ACCEPT_ENCODING))?;
if options.compressed && request.get_header_values(ACCEPT_ENCODING).is_empty() {
list.append(&format!("{}: gzip, deflate, br", ACCEPT_ENCODING))?;
}

self.handle.http_headers(list)?;
Expand Down
129 changes: 123 additions & 6 deletions packages/hurl/src/http/header.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,14 @@
*
*/
use core::fmt;
use std::slice::Iter;

pub const ACCEPT_ENCODING: &str = "Accept-Encoding";
pub const AUTHORIZATION: &str = "Authorization";
pub const COOKIE: &str = "Cookie";
pub const CONTENT_TYPE: &str = "Content-Type";
pub const EXPECT: &str = "Expect";
pub const USER_AGENT: &str = "User-Agent";

/// Represents an HTTP header
#[derive(Clone, Debug, PartialEq, Eq)]
Expand All @@ -31,12 +39,6 @@ impl fmt::Display for Header {
}

impl Header {
pub const ACCEPT_ENCODING: &'static str = "Accept-Encoding";
pub const AUTHORIZATION: &'static str = "Authorization";
pub const CONTENT_TYPE: &'static str = "Content-Type";
pub const EXPECT: &'static str = "Expect";
pub const USER_AGENT: &'static str = "User-Agent";

pub fn new(name: &str, value: &str) -> Self {
Header {
name: name.to_string(),
Expand All @@ -58,3 +60,118 @@ pub fn get_values(headers: &[Header], name: &str) -> Vec<String> {
})
.collect()
}

/// Represents an ordered list of [`Header`].
/// The headers are sorted by insertion order.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct HeaderVec {
headers: Vec<Header>,
}

impl HeaderVec {
/// Creates an empty [`HeaderVec`].
pub fn new() -> Self {
HeaderVec::default()
}

/// Returns a reference to the header associated with `name`.
///
/// If there are multiple headers associated with `name`, then the first one is returned.
/// Use [`get_all`] to get all values associated with a given key.
pub fn get(&self, name: &str) -> Option<&Header> {
self.headers
.iter()
.find(|h| h.name.to_lowercase() == name.to_lowercase())
}

/// Returns a list of header associated with `name`.
pub fn get_all(&self, name: &str) -> Vec<&Header> {
self.headers
.iter()
.filter(|h| h.name.to_lowercase() == name.to_lowercase())
.collect()
}

/// Returns an iterator over all the headers.
pub fn iter(&self) -> impl Iterator<Item = &Header> {
self.headers.iter()
}

/// Returns the number of headers stored in the list.
///
/// This number represents the total numbers of header, including header with the same name and
/// different values.
pub fn len(&self) -> usize {
self.headers.len()
}

/// Push a new `header` into the headers list.
pub fn push(&mut self, header: Header) {
self.headers.push(header)
}
}

impl<'a> IntoIterator for &'a HeaderVec {
type Item = &'a Header;
type IntoIter = Iter<'a, Header>;

fn into_iter(self) -> Self::IntoIter {
self.headers.iter()
}
}

#[cfg(test)]
mod tests {
use crate::http::header::HeaderVec;
use crate::http::Header;

#[test]
fn test_simple_header_map() {
let mut headers = HeaderVec::new();
headers.push(Header::new("foo", "xxx"));
headers.push(Header::new("bar", "yyy0"));
headers.push(Header::new("bar", "yyy1"));
headers.push(Header::new("bar", "yyy2"));
headers.push(Header::new("baz", "zzz"));

assert_eq!(headers.len(), 5);

assert_eq!(headers.get("foo"), Some(&Header::new("foo", "xxx")));
assert_eq!(headers.get("FOO"), Some(&Header::new("foo", "xxx")));
assert_eq!(headers.get("bar"), Some(&Header::new("bar", "yyy0")));
assert_eq!(headers.get("qux"), None);

assert_eq!(
headers.get_all("bar"),
vec![
&Header::new("bar", "yyy0"),
&Header::new("bar", "yyy1"),
&Header::new("bar", "yyy2"),
]
);
assert_eq!(headers.get_all("BAZ"), vec![&Header::new("baz", "zzz")]);
assert_eq!(headers.get_all("qux"), Vec::<&Header>::new());
}

#[test]
fn test_iter() {
let data = vec![("foo", "xxx"), ("bar", "yyy0"), ("baz", "yyy1")];
let mut headers = HeaderVec::new();
data.iter()
.for_each(|(name, value)| headers.push(Header::new(name, value)));

// Test iter()
for (i, h) in headers.iter().enumerate() {
assert_eq!(h.name, data[i].0);
assert_eq!(h.value, data[i].1)
}

// Test into_iter()
let mut i = 0;
for h in &headers {
assert_eq!(h.name, data[i].0);
assert_eq!(h.value, data[i].1);
i += 1;
}
}
}
4 changes: 3 additions & 1 deletion packages/hurl/src/http/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ pub(crate) use self::client::Client;
pub use self::cookie::{CookieAttribute, ResponseCookie};
pub(crate) use self::core::{Cookie, Param, RequestCookie};
pub(crate) use self::error::HttpError;
pub use self::header::Header;
pub use self::header::{
Header, ACCEPT_ENCODING, AUTHORIZATION, CONTENT_TYPE, COOKIE, EXPECT, USER_AGENT,
};
pub(crate) use self::options::{ClientOptions, Verbosity};
pub use self::request::{IpResolve, Request, RequestedHttpVersion};
pub(crate) use self::request_spec::{Body, FileParam, Method, MultipartParam, RequestSpec};
Expand Down
58 changes: 29 additions & 29 deletions packages/hurl/src/http/request.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ use std::fmt;
use url::Url;

use crate::http::core::*;
use crate::http::{header, Header, HttpError};
use crate::http::header::{HeaderVec, CONTENT_TYPE, COOKIE};
use crate::http::HttpError;

/// Represents a runtime HTTP request.
/// This is a real request, that has been executed by our HTTP client.
Expand All @@ -31,7 +32,7 @@ use crate::http::{header, Header, HttpError};
pub struct Request {
pub url: String,
pub method: String,
pub headers: Vec<Header>,
pub headers: HeaderVec,
pub body: Vec<u8>,
}

Expand Down Expand Up @@ -68,7 +69,7 @@ pub enum IpResolve {

impl Request {
/// Creates a new request.
pub fn new(method: &str, url: &str, headers: Vec<Header>, body: Vec<u8>) -> Self {
pub fn new(method: &str, url: &str, headers: HeaderVec, body: Vec<u8>) -> Self {
Request {
url: url.to_string(),
method: method.to_string(),
Expand Down Expand Up @@ -96,17 +97,15 @@ impl Request {
/// see <https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cookie>
pub fn cookies(&self) -> Vec<RequestCookie> {
self.headers
.get_all(COOKIE)
.iter()
.filter(|h| h.name.as_str() == "Cookie")
.flat_map(|h| parse_cookies(h.value.as_str().trim()))
.collect()
}

/// Returns optional Content-type header value.
pub fn content_type(&self) -> Option<String> {
header::get_values(&self.headers, Header::CONTENT_TYPE)
.first()
.cloned()
pub fn content_type(&self) -> Option<&str> {
self.headers.get(CONTENT_TYPE).map(|h| h.value.as_str())
}

/// Returns the base url http(s)://host(:port)
Expand Down Expand Up @@ -152,32 +151,26 @@ fn parse_cookie(s: &str) -> RequestCookie {
#[cfg(test)]
mod tests {
use super::*;
use crate::http::RequestCookie;
use crate::http::{Header, RequestCookie};

fn hello_request() -> Request {
Request::new(
"GET",
"http://localhost:8000/hello",
vec![
Header::new("Host", "localhost:8000"),
Header::new("Accept", "*/*"),
Header::new("User-Agent", "hurl/1.0"),
],
vec![],
)
let mut headers = HeaderVec::new();
headers.push(Header::new("Host", "localhost:8000"));
headers.push(Header::new("Accept", "*/*"));
headers.push(Header::new("User-Agent", "hurl/1.0"));
headers.push(Header::new("content-type", "application/json"));

Request::new("GET", "http://localhost:8000/hello", headers, vec![])
}

fn query_string_request() -> Request {
Request::new("GET", "http://localhost:8000/querystring-params?param1=value1&param2=&param3=a%3Db&param4=1%2C2%2C3", vec![], vec![])
Request::new("GET", "http://localhost:8000/querystring-params?param1=value1&param2=&param3=a%3Db&param4=1%2C2%2C3", HeaderVec::new(), vec![])
}

fn cookies_request() -> Request {
Request::new(
"GET",
"http://localhost:8000/cookies",
vec![Header::new("Cookie", "cookie1=value1; cookie2=value2")],
vec![],
)
let mut headers = HeaderVec::new();
headers.push(Header::new("Cookie", "cookie1=value1; cookie2=value2"));
Request::new("GET", "http://localhost:8000/cookies", headers, vec![])
}

#[test]
Expand Down Expand Up @@ -206,6 +199,13 @@ mod tests {
)
}

#[test]
fn test_content_type() {
assert_eq!(hello_request().content_type(), Some("application/json"));
assert_eq!(query_string_request().content_type(), None);
assert_eq!(cookies_request().content_type(), None);
}

#[test]
fn test_cookies() {
assert!(hello_request().cookies().is_empty());
Expand Down Expand Up @@ -255,7 +255,7 @@ mod tests {
#[test]
fn test_base_url() {
assert_eq!(
Request::new("", "http://localhost", vec![], vec![])
Request::new("", "http://localhost", HeaderVec::new(), vec![])
.base_url()
.unwrap(),
"http://localhost".to_string()
Expand All @@ -264,15 +264,15 @@ mod tests {
Request::new(
"",
"http://localhost:8000/redirect-relative",
vec![],
HeaderVec::new(),
vec![]
)
.base_url()
.unwrap(),
"http://localhost:8000".to_string()
);
assert_eq!(
Request::new("", "https://localhost:8000", vec![], vec![])
Request::new("", "https://localhost:8000", HeaderVec::new(), vec![])
.base_url()
.unwrap(),
"https://localhost:8000".to_string()
Expand Down
2 changes: 1 addition & 1 deletion packages/hurl/src/http/request_debug.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ impl Request {
// If it ok, we print each line of the body in debug format. Otherwise, we
// print the body first 64 bytes.
if let Some(content_type) = self.content_type() {
if !mimetype::is_kind_of_text(&content_type) {
if !mimetype::is_kind_of_text(content_type) {
debug::log_bytes(&self.body, 64, debug, logger);
return;
}
Expand Down
2 changes: 1 addition & 1 deletion packages/hurl/src/http/request_decoding.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ impl Request {
/// Returns character encoding of the HTTP request.
fn character_encoding(&self) -> Result<EncodingRef, HttpError> {
match self.content_type() {
Some(content_type) => match mimetype::charset(&content_type) {
Some(content_type) => match mimetype::charset(content_type) {
Some(charset) => {
match encoding::label::encoding_from_whatwg_label(charset.as_str()) {
None => Err(HttpError::InvalidCharset { charset }),
Expand Down
Loading

0 comments on commit 6d77de1

Please sign in to comment.