use candid::{CandidType, Deserialize};
use serde_bytes::ByteBuf;
#[derive(Clone, Debug, CandidType, Deserialize)]
pub struct HttpRequest {
pub method: String,
pub url: String,
pub headers: Vec<(String, String)>,
pub body: ByteBuf,
}
impl HttpRequest {
pub fn path(&self) -> &str {
match self.url.find('?') {
None => &self.url[..],
Some(index) => &self.url[..index],
}
}
pub fn raw_query_param(&self, param: &str) -> Option<&str> {
const QUERY_SEPARATOR: &str = "?";
let query_string = self.url.split(QUERY_SEPARATOR).nth(1)?;
if query_string.is_empty() {
return None;
}
const PARAMETER_SEPARATOR: &str = "&";
for chunk in query_string.split(PARAMETER_SEPARATOR) {
const KEY_VALUE_SEPARATOR: &str = "=";
let mut split = chunk.splitn(2, KEY_VALUE_SEPARATOR);
let name = split.next()?;
if name == param {
return Some(split.next().unwrap_or_default());
}
}
None
}
}
#[derive(Clone, Debug, CandidType, Deserialize)]
pub struct HttpResponse {
pub status_code: u16,
pub headers: Vec<(String, String)>,
pub body: ByteBuf,
}
pub struct HttpResponseBuilder(HttpResponse);
impl HttpResponseBuilder {
pub fn ok() -> Self {
Self(HttpResponse {
status_code: 200,
headers: vec![],
body: ByteBuf::default(),
})
}
pub fn bad_request() -> Self {
Self(HttpResponse {
status_code: 400,
headers: vec![],
body: ByteBuf::from("bad request"),
})
}
pub fn not_found() -> Self {
Self(HttpResponse {
status_code: 404,
headers: vec![],
body: ByteBuf::from("not found"),
})
}
pub fn server_error(reason: impl ToString) -> Self {
Self(HttpResponse {
status_code: 500,
headers: vec![],
body: ByteBuf::from(reason.to_string()),
})
}
pub fn header(mut self, name: impl ToString, value: impl ToString) -> Self {
self.0.headers.push((name.to_string(), value.to_string()));
self
}
pub fn body(mut self, bytes: impl Into<Vec<u8>>) -> Self {
self.0.body = ByteBuf::from(bytes.into());
self
}
pub fn with_body_and_content_length(self, bytes: impl Into<Vec<u8>>) -> Self {
let bytes = bytes.into();
self.header("Content-Length", bytes.len()).body(bytes)
}
pub fn build(self) -> HttpResponse {
self.0
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn path_returns_full_url_when_no_query_string() {
let http_request = HttpRequest {
method: "GET".to_string(),
url: "/path/to/resource".to_string(),
headers: vec![],
body: Default::default(),
};
assert_eq!(http_request.path(), "/path/to/resource");
}
#[test]
fn path_returns_path_without_query_string() {
let http_request = HttpRequest {
method: "GET".to_string(),
url: "/path/to/resource?query=1".to_string(),
headers: vec![],
body: Default::default(),
};
assert_eq!(http_request.path(), "/path/to/resource");
}
#[test]
fn path_handles_empty_url() {
let http_request = HttpRequest {
method: "GET".to_string(),
url: "".to_string(),
headers: vec![],
body: Default::default(),
};
assert_eq!(http_request.path(), "");
}
#[test]
fn raw_query_param_returns_none_for_empty_query_string() {
let http_request = HttpRequest {
method: "GET".to_string(),
url: "/endpoint?".to_string(),
headers: vec![],
body: Default::default(),
};
assert_eq!(http_request.raw_query_param("key"), None);
}
#[test]
fn raw_query_param_returns_none_for_missing_key() {
let http_request = HttpRequest {
method: "GET".to_string(),
url: "/endpoint?other=value".to_string(),
headers: vec![],
body: Default::default(),
};
assert_eq!(http_request.raw_query_param("key"), None);
}
#[test]
fn raw_query_param_returns_empty_value_for_key_without_value() {
let http_request = HttpRequest {
method: "GET".to_string(),
url: "/endpoint?key=".to_string(),
headers: vec![],
body: Default::default(),
};
assert_eq!(http_request.raw_query_param("key"), Some(""));
}
#[test]
fn raw_query_param_handles_multiple_keys_with_same_name() {
let http_request = HttpRequest {
method: "GET".to_string(),
url: "/endpoint?key=value1&key=value2".to_string(),
headers: vec![],
body: Default::default(),
};
assert_eq!(http_request.raw_query_param("key"), Some("value1"));
}
#[test]
fn raw_query_param_handles_url_without_query_separator() {
let http_request = HttpRequest {
method: "GET".to_string(),
url: "/endpoint".to_string(),
headers: vec![],
body: Default::default(),
};
assert_eq!(http_request.raw_query_param("key"), None);
}
#[test]
fn raw_query_param_returns_none_for_partial_match() {
let http_request = HttpRequest {
method: "GET".to_string(),
url: "/endpoint?key1=value1".to_string(),
headers: vec![],
body: Default::default(),
};
assert_eq!(http_request.raw_query_param("key"), None);
}
#[test]
fn ok_response_has_status_200() {
let response = HttpResponseBuilder::ok().build();
assert_eq!(response.status_code, 200);
assert!(response.body.is_empty());
}
#[test]
fn bad_request_response_has_status_400_and_default_body() {
let response = HttpResponseBuilder::bad_request().build();
assert_eq!(response.status_code, 400);
assert_eq!(response.body, ByteBuf::from("bad request"));
}
#[test]
fn not_found_response_has_status_404_and_default_body() {
let response = HttpResponseBuilder::not_found().build();
assert_eq!(response.status_code, 404);
assert_eq!(response.body, ByteBuf::from("not found"));
}
#[test]
fn server_error_response_has_status_500_and_custom_body() {
let response = HttpResponseBuilder::server_error("internal error").build();
assert_eq!(response.status_code, 500);
assert_eq!(response.body, ByteBuf::from("internal error"));
}
#[test]
fn response_builder_adds_headers_correctly() {
let response = HttpResponseBuilder::ok()
.header("Content-Type", "application/json")
.header("Cache-Control", "no-cache")
.build();
assert_eq!(
response.headers,
vec![
("Content-Type".to_string(), "application/json".to_string()),
("Cache-Control".to_string(), "no-cache".to_string())
]
);
}
#[test]
fn response_builder_sets_body_correctly() {
let response = HttpResponseBuilder::ok().body("response body").build();
assert_eq!(response.body, ByteBuf::from("response body"));
}
#[test]
fn response_builder_sets_body_and_content_length() {
let response = HttpResponseBuilder::ok()
.with_body_and_content_length("response body")
.build();
assert_eq!(response.body, ByteBuf::from("response body"));
assert_eq!(
response.headers,
vec![("Content-Length".to_string(), "13".to_string())]
);
}
}