use crate::{
body::Body,
content_encoding::{ContentContentEncoding, EncodingAccepted},
file::File,
pack::Pack,
};
use http::{
HeaderMap, Method, StatusCode, header,
response::{Builder as ResponseBuilder, Response as HttpResponse},
};
pub type Response<'a> = HttpResponse<Body<'a>>;
#[derive(Debug)]
pub struct Responder<'p, P>
where
P: Pack,
{
pack: &'p P,
}
impl<'p, P> Responder<'p, P>
where
P: Pack,
{
pub const fn new(pack: &'p P) -> Self {
Self { pack }
}
pub fn respond(
&self,
method: &Method,
path: &str,
headers: &HeaderMap,
) -> Result<Response<'p>, ResponderRespondError> {
let body_in_response = match *method {
Method::GET => true,
Method::HEAD => false,
_ => {
return Err(ResponderRespondError::HttpMethodNotSupported);
}
};
let file = match self.pack.get_file_by_path(path) {
Some(file_descriptor) => file_descriptor,
None => {
return Err(ResponderRespondError::PackPathNotFound);
}
};
if let Some(etag_request) = headers.get(header::IF_NONE_MATCH)
&& etag_request.as_bytes() == file.etag().as_bytes()
{
let response = ResponseBuilder::new()
.status(StatusCode::NOT_MODIFIED)
.header(header::ETAG, file.etag()) .body(Body::empty())
.unwrap();
return Ok(response);
};
let content_content_encoding = ContentContentEncoding::resolve(
&match EncodingAccepted::from_headers(headers) {
Ok(content_encoding_encoding_accepted) => content_encoding_encoding_accepted,
Err(_) => return Err(ResponderRespondError::UnparsableAcceptEncoding),
},
file,
);
let response = ResponseBuilder::new()
.header(header::CONTENT_TYPE, file.content_type())
.header(header::ETAG, file.etag())
.header(header::CACHE_CONTROL, file.cache_control().cache_control())
.header(
header::CONTENT_LENGTH,
content_content_encoding.content.len(),
)
.header(
header::CONTENT_ENCODING,
content_content_encoding.content_encoding,
)
.body(if body_in_response {
Body::new(content_content_encoding.content)
} else {
Body::empty()
})
.unwrap();
Ok(response)
}
pub fn respond_flatten(
&self,
method: &Method,
path: &str,
headers: &HeaderMap,
) -> Response<'p> {
match self.respond(method, path, headers) {
Ok(response) => response,
Err(responder_error) => responder_error.into_response(),
}
}
}
#[derive(PartialEq, Eq, Debug)]
pub enum ResponderRespondError {
HttpMethodNotSupported,
PackPathNotFound,
UnparsableAcceptEncoding,
}
impl ResponderRespondError {
pub fn status_code(&self) -> StatusCode {
match self {
ResponderRespondError::HttpMethodNotSupported => StatusCode::METHOD_NOT_ALLOWED,
ResponderRespondError::PackPathNotFound => StatusCode::NOT_FOUND,
ResponderRespondError::UnparsableAcceptEncoding => StatusCode::BAD_REQUEST,
}
}
pub fn into_response(&self) -> Response<'static> {
let response = ResponseBuilder::new()
.status(self.status_code())
.body(Body::empty())
.unwrap();
response
}
}
#[cfg(test)]
mod test_responder {
use super::{Responder, ResponderRespondError};
use crate::{cache_control::CacheControl, file::File, pack::Pack};
use anyhow::anyhow;
use http::{HeaderMap, HeaderName, HeaderValue, header, method::Method, status::StatusCode};
struct FileMock;
impl File for FileMock {
fn content(&self) -> &[u8] {
b"content-identity"
}
fn content_gzip(&self) -> Option<&[u8]> {
None
}
fn content_brotli(&self) -> Option<&[u8]> {
Some(b"content-br")
}
fn content_type(&self) -> HeaderValue {
HeaderValue::from_static("text/plain; charset=utf-8")
}
fn etag(&self) -> HeaderValue {
HeaderValue::from_static("\"etagvalue\"")
}
fn cache_control(&self) -> CacheControl {
CacheControl::MaxCache
}
}
struct PackMock;
impl Pack for PackMock {
type File = FileMock;
fn get_file_by_path(
&self,
path: &str,
) -> Option<&Self::File> {
match path {
"/present" => Some(&FileMock),
_ => None,
}
}
}
static RESPONDER: Responder<'static, PackMock> = Responder::new(&PackMock);
fn header_as_string(
headers: &HeaderMap,
name: HeaderName,
) -> &str {
headers
.get(&name)
.ok_or_else(|| anyhow!("missing header {name}"))
.unwrap()
.to_str()
.unwrap()
}
#[test]
fn resolves_typical_request() {
let response = RESPONDER
.respond(
&Method::GET,
"/present",
&[
(
header::ACCEPT_ENCODING,
HeaderValue::from_static("br, gzip"),
),
(
header::IF_NONE_MATCH,
HeaderValue::from_static("\"invalidetag\""),
),
]
.into_iter()
.collect::<HeaderMap>(),
)
.unwrap();
let headers = response.headers();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
header_as_string(headers, header::CONTENT_TYPE),
"text/plain; charset=utf-8"
);
assert_eq!(
header_as_string(headers, header::ETAG), "\"etagvalue\""
);
assert_eq!(
header_as_string(headers, header::CACHE_CONTROL), "max-age=31536000, immutable"
);
assert_eq!(
header_as_string(headers, header::CONTENT_LENGTH), "10"
);
assert_eq!(
header_as_string(headers, header::CONTENT_ENCODING), "br"
);
assert_eq!(response.body().data(), b"content-br");
}
#[test]
fn resolves_no_body_for_head_request() {
let response = RESPONDER
.respond(&Method::HEAD, "/present", &HeaderMap::default())
.unwrap();
let headers = response.headers();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
header_as_string(headers, header::CONTENT_TYPE),
"text/plain; charset=utf-8"
);
assert_eq!(
header_as_string(headers, header::ETAG), "\"etagvalue\""
);
assert_eq!(
header_as_string(headers, header::CONTENT_LENGTH), "16"
);
assert_eq!(
header_as_string(headers, header::CONTENT_ENCODING),
"identity"
);
assert_eq!(response.body().data(), b"");
}
#[test]
fn resolves_not_modified_for_matching_etag() {
let response = RESPONDER
.respond(
&Method::GET,
"/present",
&[(
header::IF_NONE_MATCH,
HeaderValue::from_static("\"etagvalue\""),
)]
.into_iter()
.collect::<HeaderMap>(),
)
.unwrap();
let headers = response.headers();
assert_eq!(response.status(), StatusCode::NOT_MODIFIED);
assert_eq!(
header_as_string(headers, header::ETAG), "\"etagvalue\""
);
assert!(headers.get(header::CONTENT_TYPE).is_none());
assert_eq!(response.body().data(), b"");
}
#[test]
fn resolves_error_for_invalid_method() {
let response_error = RESPONDER
.respond(&Method::POST, "/present", &HeaderMap::default())
.unwrap_err();
assert_eq!(
response_error,
ResponderRespondError::HttpMethodNotSupported
);
let response_flatten = response_error.into_response();
assert_eq!(response_flatten.status(), StatusCode::METHOD_NOT_ALLOWED);
}
#[test]
fn resolves_error_for_file_not_found() {
let response_error = RESPONDER
.respond(&Method::GET, "/missing", &HeaderMap::default())
.unwrap_err();
assert_eq!(response_error, ResponderRespondError::PackPathNotFound);
let response_flatten = response_error.into_response();
assert_eq!(response_flatten.status(), StatusCode::NOT_FOUND);
}
}