use error::RequestParseError;
use header::{parse_header, HeaderToken};
use method::parse_method_input;
use uri::parse_uri_input;
use variables::{parse_variable, parse_variable_declaration};
use version::parse_http_version;
pub mod error;
mod header;
mod method;
mod uri;
mod variables;
mod version;
#[derive(Copy, Clone, PartialEq)]
enum ParserMode {
Request,
Headers,
Body,
}
#[derive(Debug)]
enum RequestToken {
Method(http::method::Method),
Uri(http::uri::Uri),
HttpVersion(http::version::Version),
Header(HeaderToken),
Body(Option<String>),
}
#[inline]
fn to_enum_chars(input: &str) -> core::iter::Enumerate<core::str::Chars> {
input.chars().enumerate()
}
#[inline]
fn tokenize(
buffer: &str,
input_variables: &std::collections::HashMap<String, String>,
) -> Result<Vec<RequestToken>, RequestParseError> {
let mut tokens: Vec<RequestToken> = Vec::new();
let mut parser_mode = ParserMode::Request;
let mut body_parts: Vec<String> = Vec::new();
let mut vars = input_variables.to_owned();
for line in buffer.lines() {
let trimmed_line = line.trim();
if trimmed_line.starts_with('#') {
if trimmed_line.starts_with("###") && parser_mode != ParserMode::Request {
if body_parts.is_empty() {
tokens.push(RequestToken::Body(None));
} else {
tokens.push(RequestToken::Body(Some(body_parts.join("\n"))));
body_parts.clear();
};
parser_mode = ParserMode::Request;
}
if parser_mode == ParserMode::Request {
continue;
}
} else if trimmed_line.starts_with("//") {
if parser_mode == ParserMode::Request {
continue;
}
}
match parser_mode {
ParserMode::Request => {
if trimmed_line.starts_with('@') {
let mut chrs = to_enum_chars(trimmed_line);
chrs.next();
if let Some((name, value)) = parse_variable_declaration(&mut chrs, &vars)? {
vars.insert(name, value);
continue;
}
}
if !trimmed_line.is_empty() {
let mut chrs = to_enum_chars(trimmed_line);
let method = parse_method_input(&mut chrs, &vars)?;
tokens.push(RequestToken::Method(method));
let uri = parse_uri_input(&mut chrs, &vars)?;
tokens.push(RequestToken::Uri(uri));
if let Some(http_version) = parse_http_version(&mut chrs, &vars) {
tokens.push(RequestToken::HttpVersion(http_version));
}
parser_mode = ParserMode::Headers;
}
}
ParserMode::Headers => {
if trimmed_line.is_empty() {
parser_mode = ParserMode::Body;
} else if let Some(header_token) =
parse_header(&mut to_enum_chars(trimmed_line), &vars)?
{
tokens.push(RequestToken::Header(header_token));
}
}
ParserMode::Body => {
let mut current_line = String::new();
let mut chars = to_enum_chars(line);
while let Some((_, ch)) = chars.next() {
if ch == '{' {
if let Some((var, jumps)) = parse_variable(&mut chars.clone()) {
if let Some(variable_value) = vars.get(&var) {
current_line.push_str(variable_value);
for _ in 0..jumps {
chars.next();
}
continue;
}
return Err(RequestParseError::VariableNotFound(var));
}
}
current_line.push(ch);
}
body_parts.push(current_line);
}
};
}
if !body_parts.is_empty() {
tokens.push(RequestToken::Body(Some(body_parts.join("\n"))));
}
Ok(tokens)
}
#[cfg(test)]
mod test_tokenize {
use core::fmt::Write as _;
use once_cell::sync::Lazy;
use crate::{error::RequestParseError, tokenize, RequestToken};
static EMPTY_VARS: Lazy<std::collections::HashMap<String, String>> =
Lazy::new(std::collections::HashMap::new);
#[test]
fn should_return_a_list_of_tokens() {
let method_input = "GET";
let uri_input = "https://mhouge.dk/";
let http_version = "HTTP/2";
let header1_key = "content-type";
let header1_value = "application/json";
let body_input = "{ \"key\": \"value\" }";
let input_request =
format!("{method_input} {uri_input} {http_version}\n{header1_key}: {header1_value}\n\n{body_input}");
let tokens =
tokenize(&input_request, &EMPTY_VARS).expect("it to return Result<Vec<RequestToken>>");
assert_eq!(tokens.len(), 5);
for token in tokens {
match token {
RequestToken::Uri(uri_token) => assert_eq!(uri_input, uri_token.to_string()),
RequestToken::Method(method_token) => {
assert_eq!(method_input, method_token.as_str());
}
RequestToken::Header(header_token) => {
assert_eq!(header1_key, header_token.key.to_string());
assert_eq!(
header1_value,
header_token
.value
.to_str()
.expect("value to be a valid str")
);
}
RequestToken::Body(body_token) => {
assert!(body_token.is_some());
let body_inner = body_token.expect("body to be defined");
assert_eq!(body_input, body_inner);
}
RequestToken::HttpVersion(version_token) => {
assert_eq!(version_token, http::version::Version::HTTP_2);
}
}
}
}
#[test]
fn it_should_be_able_to_parse_multiple_requests() {
let uri = "https://mhouge.dk/";
let methods = [
http::Method::GET,
http::Method::PUT,
http::Method::POST,
http::Method::PATCH,
http::Method::DELETE,
http::Method::OPTIONS,
http::Method::HEAD,
http::Method::TRACE,
http::Method::CONNECT,
];
let versions = [
http::Version::HTTP_09,
http::Version::HTTP_10,
http::Version::HTTP_11,
http::Version::HTTP_2,
http::Version::HTTP_3,
];
let header_key = "x-test-header";
let header_value = "test-value";
let body = "mads was here\n".to_owned();
let mut input = String::new();
let mut input_request_index: u16 = 0;
for method in &methods {
for version in &versions {
writeln!(input, "{method} {uri} {version:?}").expect("it to write");
writeln!(input, "{header_key}: {header_value}\n").expect("it to write");
if input_request_index % 2 == 0 {
writeln!(input, "{body}").expect("it to write");
}
writeln!(input, "###\n").expect("it to write");
input_request_index += 1;
}
}
let tokens = tokenize(&input, &EMPTY_VARS).expect("it to return a list of tokens");
assert_eq!(tokens.len(), methods.len() * versions.len() * 5);
let mut output_request_index: u16 = 0;
let mut token_index = 0;
let body_option = Some(body);
for method in &methods {
for version in &versions {
let method_token = tokens.get(token_index).expect("it to be a method token");
assert!(matches!(method_token, RequestToken::Method(m) if m == method));
token_index += 1;
let uri_token = tokens.get(token_index).expect("it to be an uri token");
assert!(matches!(uri_token, RequestToken::Uri(u) if u == uri));
token_index += 1;
let version_token = tokens.get(token_index).expect("it to be a version token");
assert!(matches!(version_token, RequestToken::HttpVersion(v) if v == version));
token_index += 1;
let header_token = tokens.get(token_index).expect("it to be a header token");
assert!(
matches!(header_token, RequestToken::Header(h) if h.key == header_key && h.value == header_value)
);
token_index += 1;
let body_token = tokens.get(token_index).expect("it to be a body token");
if output_request_index % 2 == 0 {
assert!(matches!(body_token, RequestToken::Body(b) if b == &body_option));
} else {
assert!(matches!(body_token, RequestToken::Body(b) if b.is_none()));
}
token_index += 1;
output_request_index += 1;
}
}
}
#[test]
fn it_should_ignore_comments() {
let input = "
// comment 1
# comment 2
DELETE https://mhouge.dk/";
let tokens = tokenize(input, &EMPTY_VARS).expect("it to parse successfully");
assert_eq!(tokens.len(), 2);
let method_token = tokens.first().expect("it to be some");
assert!(
matches!(method_token, RequestToken::Method(m) if m == http::method::Method::DELETE)
);
let uri_token = tokens.get(1).expect("it to be Some");
let expected_uri = "https://mhouge.dk/";
assert!(matches!(uri_token, RequestToken::Uri(uri) if uri == expected_uri));
}
#[test]
fn it_should_only_check_for_comments_when_parsermode_request() {
let url = "https://mhouge.dk/api/something/?refresh=true";
let method = "DELETE";
let status_line = format!("{method} {url}");
for comment_style in ["#", "//"] {
let body = format!("{comment_style} this is not a comment");
let hashtag = format!(
"{status_line}
{body}"
);
let tokens = tokenize(&hashtag, &EMPTY_VARS).expect("it to parse successfully");
assert_eq!(tokens.len(), 3);
let method_token = tokens.first().expect("it to be some");
assert!(
matches!(method_token, RequestToken::Method(m) if m == http::method::Method::DELETE)
);
let uri_token = tokens.get(1).expect("it to be Some");
assert!(matches!(uri_token, RequestToken::Uri(u) if u == url));
let body_token = tokens.get(2).expect("it to be Some");
assert!(matches!(body_token, RequestToken::Body(b) if b == &Some(body)));
}
}
#[test]
fn it_should_support_variables() {
{
let input = "
@method = GET
@host = https://mhouge.dk
@path = /api
@query_value = mads@mhouge.dk
@body_input = { \"key\": \"value\" }
{{method}} {{host}}{{path}}?email={{query_value}}
{{ body_input }}";
let tokens = tokenize(input, &EMPTY_VARS).expect("it to tokenize successfully");
assert_eq!(tokens.len(), 3);
let method_token = tokens.first().expect("it to be some");
assert!(
matches!(method_token, RequestToken::Method(m) if m == http::method::Method::GET)
);
let uri_token = tokens.get(1).expect("it to be Some");
let expected_uri = "https://mhouge.dk/api?email=mads@mhouge.dk";
assert!(matches!(uri_token, RequestToken::Uri(uri) if uri == expected_uri));
let body_token = tokens.get(2).expect("it to be set");
let expected_body_value = "{ \"key\": \"value\" }";
assert!(matches!(
body_token,
RequestToken::Body(value)
if value.clone().expect( "value to exist") == expected_body_value
));
};
{
let input = "
GET https://mhouge.dk/
{{ body_input }}";
let tokens = tokenize(input, &EMPTY_VARS).expect_err("it to return an error");
assert!(matches!(
tokens,
RequestParseError::VariableNotFound(var)
if var == "body_input"
));
}
}
#[test]
fn it_should_support_input_variables() {
let vars = std::collections::HashMap::from([
("method".to_owned(), "GET".to_owned()),
("host".to_owned(), "https://mhouge.dk".to_owned()),
("path".to_owned(), "/api".to_owned()),
("query_value".to_owned(), "mads@mhouge.dk".to_owned()),
("body_input".to_owned(), "{ \"key\": \"value\" }".to_owned()),
]);
let input = "
{{method}} {{host}}{{path}}?email={{query_value}}
{{ body_input }}";
let tokens = tokenize(input, &vars).expect("it to tokenize successfully");
assert_eq!(tokens.len(), 3);
let method_token = tokens.first().expect("it to return token");
assert!(
matches!(method_token, RequestToken::Method(method) if method == http::Method::GET)
);
let expected_uri = "https://mhouge.dk/api?email=mads@mhouge.dk";
let uri_token = tokens.get(1).expect("it to return an uri");
assert!(matches!(uri_token, RequestToken::Uri(uri) if uri == expected_uri));
let body_token = tokens.get(2).expect("it to return a token");
let expected_body = "{ \"key\": \"value\" }";
assert!(
matches!(body_token, RequestToken::Body(Some(output_body)) if output_body == expected_body)
);
}
#[test]
fn it_should_raise_error_if_missing_variable() {
let input = "GET {{missing_variable}}";
let err = tokenize(input, &EMPTY_VARS).expect_err("it to be a missing variable error");
assert!(matches!(err, RequestParseError::VariableNotFound(v) if v == "missing_variable"));
}
#[test]
fn input_variables_should_be_overwritten_by_local_variables() {
let vars = std::collections::HashMap::from([("method".to_owned(), "PUT".to_owned())]);
let input = "
@method = POST
{{method}} https://mhouge.dk/";
let tokens = tokenize(input, &vars).expect("it to parse successfully");
assert_eq!(tokens.len(), 2);
let method_token = tokens.first().expect("it to return token");
assert!(
matches!(method_token, RequestToken::Method(method) if method == http::Method::POST)
);
let uri_token = tokens.get(1).expect("it to return token");
assert!(matches!(uri_token, RequestToken::Uri(uri) if uri == "https://mhouge.dk/"));
}
#[test]
fn it_should_ignore_triple_hashtag_when_in_parsermode_request() {
let input = "
###
###
###
OPTIONS https://mhouge.dk/
###
###
###
HEAD https://mhouge.dk/blog/";
let output = tokenize(input, &EMPTY_VARS).expect("it to parse");
assert_eq!(output.len(), 5);
{
let method = output.first().expect("it to return a method token");
assert!(matches!(method, RequestToken::Method(m) if m == http::Method::OPTIONS));
let uri = output.get(1).expect("it to return a uri token");
assert!(matches!(uri, RequestToken::Uri(u) if u == "https://mhouge.dk/"));
let body = output.get(2).expect("it to be an empty body token");
assert!(matches!(body, RequestToken::Body(b) if b.is_none()));
};
{
let method = output.get(3).expect("it to return a method token");
assert!(matches!(method, RequestToken::Method(m) if m == http::Method::HEAD));
let uri = output.get(4).expect("it to return a uri token");
assert!(matches!(uri, RequestToken::Uri(u) if u == "https://mhouge.dk/blog/"));
};
}
}
#[derive(Debug)]
pub struct HittRequest {
pub method: http::method::Method,
pub uri: http::uri::Uri,
pub headers: http::HeaderMap,
pub body: Option<String>,
pub http_version: Option<http::version::Version>,
}
#[derive(Default)]
struct PartialHittRequest {
method: Option<http::method::Method>,
uri: Option<http::uri::Uri>,
headers: http::HeaderMap,
body: Option<String>,
http_version: Option<http::version::Version>,
}
impl PartialHittRequest {
#[inline]
fn build(self) -> Result<HittRequest, RequestParseError> {
match self.method {
Some(method) => match self.uri {
Some(uri) => Ok(HittRequest {
method,
uri,
headers: self.headers,
body: self.body,
http_version: self.http_version,
}),
None => Err(RequestParseError::MissingUri),
},
None => Err(RequestParseError::MissingMethod),
}
}
}
#[cfg(test)]
mod test_partial_http_request {
use http::{HeaderMap, Uri};
use crate::{error::RequestParseError, PartialHittRequest};
#[test]
fn build_should_reject_if_no_uri() {
let request = PartialHittRequest {
uri: None,
method: Some(http::Method::GET),
http_version: None,
headers: HeaderMap::default(),
body: None,
}
.build()
.expect_err("it to raise RequestParseError::MissingUri");
assert!(matches!(request, RequestParseError::MissingUri));
}
#[test]
fn build_should_reject_if_no_method() {
let uri = Uri::from_static("https://mhouge.dk/");
let request = PartialHittRequest {
uri: Some(uri),
method: None,
http_version: None,
headers: HeaderMap::default(),
body: None,
}
.build()
.expect_err("it to raise RequestParseError::MissingMethod");
assert!(matches!(request, RequestParseError::MissingMethod));
}
}
#[inline]
pub fn parse_requests(
buffer: &str,
input_variables: &std::collections::HashMap<String, String>,
) -> Result<Vec<HittRequest>, RequestParseError> {
let mut requests = Vec::new();
let tokens = tokenize(buffer, input_variables)?;
let mut partial_request = PartialHittRequest::default();
for token in tokens {
match token {
RequestToken::Method(method) => {
partial_request.method = Some(method);
}
RequestToken::Uri(uri) => {
partial_request.uri = Some(uri);
}
RequestToken::Header(header) => {
partial_request.headers.insert(header.key, header.value);
}
RequestToken::Body(body) => {
partial_request.body = body;
requests.push(partial_request.build()?);
partial_request = PartialHittRequest::default();
}
RequestToken::HttpVersion(version_token) => {
partial_request.http_version = Some(version_token);
}
};
}
if partial_request.method.is_some() {
requests.push(partial_request.build()?);
};
Ok(requests)
}
#[cfg(test)]
mod test_parse_requests {
use core::str::FromStr;
use once_cell::sync::Lazy;
use crate::{error::RequestParseError, parse_requests};
const HTTP_METHODS: [&str; 9] = [
"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS", "CONNECT", "TRACE",
];
static EMPTY_VARS: Lazy<std::collections::HashMap<String, String>> =
Lazy::new(std::collections::HashMap::new);
#[test]
fn it_should_parse_http_method_correctly() {
let url = "https://mhouge.dk";
for method in &HTTP_METHODS {
let expected_method = http::Method::from_str(method).expect("m is a valid method");
let input = format!("{method} {url}");
let parsed_requests =
parse_requests(&input, &EMPTY_VARS).expect("request should be valid");
assert!(parsed_requests.len() == 1);
let first_request = parsed_requests.first().expect("it to be a request");
assert_eq!(expected_method, first_request.method);
let expected_uri = url.parse::<http::uri::Uri>().expect("url should be valid");
assert_eq!(expected_uri, first_request.uri);
assert_eq!(0, first_request.headers.len());
assert_eq!(None, first_request.body);
}
}
#[test]
fn it_should_be_able_to_parse_requests() {
let method_input = "GET";
let uri_input = "https://mhouge.dk/";
let header1_key = "content-type";
let header1_value = "application/json";
let body_input = "{ \"key\": \"value\" }";
let input_request =
format!("{method_input} {uri_input}\n{header1_key}: {header1_value}\n\n{body_input}");
let result =
parse_requests(&input_request, &EMPTY_VARS).expect("it to return a list of requests");
assert!(result.len() == 1);
let request = result.first().expect("request len to be 1");
assert_eq!(method_input, request.method.as_str());
assert_eq!(uri_input, request.uri.to_string());
let body_inner = request.body.clone().expect("body to be defined");
assert_eq!(body_inner, body_input);
assert_eq!(1, request.headers.len());
let header1_output = request
.headers
.get(header1_key)
.expect("header1_key to exist");
assert_eq!(
header1_value,
header1_output.to_str().expect("it to be a valid header")
);
assert!(request.http_version.is_none());
}
#[test]
fn it_should_be_able_to_parse_multiple_requests() {
let input = "
GET https://mhouge.dk/ HTTP/0.9
###
PUT https://mhouge.dk/ HTTP/1.0
###
POST https://mhouge.dk/ HTTP/1.1
###
PATCH https://mhouge.dk/ HTTP/2
###
DELETE https://mhouge.dk/ HTTP/3
###
";
let requests = parse_requests(input, &EMPTY_VARS).expect("to get a list of requests");
assert_eq!(5, requests.len());
{
let request = requests.first().expect("it to be exist");
assert_eq!(http::Method::GET, request.method);
assert_eq!("https://mhouge.dk/", request.uri.to_string());
assert!(request.headers.is_empty());
assert!(request.body.is_none());
assert_eq!(
http::Version::HTTP_09,
request.http_version.expect("http_version to be defined")
);
};
{
let request = requests.get(1).expect("it to be exist");
assert_eq!(http::Method::PUT, request.method);
assert_eq!("https://mhouge.dk/", request.uri.to_string());
assert!(request.headers.is_empty());
assert!(request.body.is_none());
assert_eq!(
http::Version::HTTP_10,
request.http_version.expect("http_version to be defined")
);
};
{
let request = requests.get(2).expect("it to be exist");
assert_eq!(http::Method::POST, request.method);
assert_eq!("https://mhouge.dk/", request.uri.to_string());
assert!(request.headers.is_empty());
assert!(request.body.is_none());
assert_eq!(
http::Version::HTTP_11,
request.http_version.expect("http_version to be defined")
);
};
{
let request = requests.get(3).expect("it to be exist");
assert_eq!(http::Method::PATCH, request.method);
assert_eq!("https://mhouge.dk/", request.uri.to_string());
assert!(request.headers.is_empty());
assert!(request.body.is_none());
assert_eq!(
http::Version::HTTP_2,
request.http_version.expect("http_version to be defined")
);
};
{
let request = requests.get(4).expect("it to be exist");
assert_eq!(http::Method::DELETE, request.method);
assert_eq!("https://mhouge.dk/", request.uri.to_string());
assert!(request.headers.is_empty());
assert!(request.body.is_none());
assert_eq!(
http::Version::HTTP_3,
request.http_version.expect("http_version to be defined")
);
};
}
#[test]
fn it_should_support_variables() {
let input = "
@method = GET
@host = https://mhouge.dk
@path = /api
@query_value = mads@mhouge.dk
@body_input = { \"key\": \"value\" }
{{method}} {{host}}{{path}}?email={{query_value}}
{{ body_input }}";
let requests = parse_requests(input, &EMPTY_VARS).expect("to get a list of requests");
assert_eq!(requests.len(), 1);
let request = requests.first().expect("it to have 1 request");
assert_eq!(request.method, http::method::Method::GET);
assert_eq!(request.uri, "https://mhouge.dk/api?email=mads@mhouge.dk");
assert_eq!(
"{ \"key\": \"value\" }",
request.body.clone().expect("body to be set"),
);
}
#[test]
fn it_should_support_variable_input() {
{
let mut vars = std::collections::HashMap::from([
("method".to_owned(), "GET".to_owned()),
("host".to_owned(), "https://mhouge.dk".to_owned()),
("path".to_owned(), "/api".to_owned()),
("query_value".to_owned(), "mads@mhouge.dk".to_owned()),
("body_input".to_owned(), "{ \"key\": \"value\" }".to_owned()),
]);
let input = "
{{method}} {{host}}{{path}}?email={{query_value}}
{{header_name}}: {{header_value}}
{{ body_input }}";
for i in u8::MIN..u8::MAX {
let header_name = format!("mads-was-here{i}");
let header_value = format!("or was i{i}?");
vars.insert("header_name".to_owned(), header_name.clone());
vars.insert("header_value".to_owned(), header_value.clone());
let requests = parse_requests(input, &vars).expect("to get a list of requests");
assert_eq!(requests.len(), 1);
let request = requests.first().expect("it to have 1 request");
assert_eq!(request.method, http::method::Method::GET);
assert_eq!(request.uri, "https://mhouge.dk/api?email=mads@mhouge.dk");
assert_eq!(request.headers.len(), 1);
let result_header_value = request.headers.get(header_name).expect("it to exist");
assert_eq!(
header_value,
result_header_value
.to_str()
.expect("it to be a valid string"),
);
assert_eq!(
"{ \"key\": \"value\" }",
request.body.clone().expect("body to be set"),
);
}
}
{
let input = "
GET https://mhouge.dk/
{{ body_input }}";
let request = parse_requests(input, &EMPTY_VARS).expect_err("it to return an error");
assert!(matches!(
request,
RequestParseError::VariableNotFound(var)
if var == "body_input"
));
}
}
#[test]
fn input_variables_should_be_overwritten_by_local_variables() {
let vars = std::collections::HashMap::from([("method".to_owned(), "PUT".to_owned())]);
let input = "
@method = POST
{{method}} https://mhouge.dk/";
let requests = parse_requests(input, &vars).expect("it to parse successfully");
assert_eq!(requests.len(), 1);
let request = requests.first().expect("it to exist");
assert_eq!(request.method, http::method::Method::POST);
assert_eq!(request.uri, "https://mhouge.dk/");
assert_eq!(request.headers.len(), 0);
}
#[test]
fn it_should_ignore_comments() {
let input = "
// comment 1
# comment 2
DELETE https://mhouge.dk/";
let requests = parse_requests(input, &EMPTY_VARS).expect("it to parse successfully");
assert_eq!(requests.len(), 1);
let request = requests.first().expect("it to exist");
assert_eq!(request.method, http::method::Method::DELETE);
let expected_uri = "https://mhouge.dk/";
assert_eq!(request.uri, expected_uri);
assert!(request.headers.is_empty());
assert!(request.body.is_none());
}
}