use crate::TransformError;
use crate::core::error_class::ErrorClass;
use serde_json::json;
use std::io::{self, Write};
use std::net::TcpStream;
pub(super) const NOT_FOUND_BODY: &str =
"{\"type\":\"about:blank\",\"title\":\"Not Found\",\"status\":404,\"detail\":\"not found\"}\n";
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct HttpResponse {
pub(super) status: &'static str,
pub(super) content_type: Option<&'static str>,
pub(super) headers: Vec<(String, String)>,
pub(super) body: Vec<u8>,
}
impl HttpResponse {
pub(super) fn json(status: &'static str, body: Vec<u8>) -> Self {
Self {
status,
content_type: Some("application/json"),
headers: Vec::new(),
body,
}
}
pub(super) fn strip_body_if_head(&mut self, is_head: bool) {
if is_head {
self.body = Vec::new();
}
}
pub(super) fn attach_request_id(&mut self, request_id: &str) {
self.headers
.push(("X-Request-Id".to_string(), request_id.to_string()));
if self.content_type != Some("application/problem+json") {
return;
}
if let Ok(serde_json::Value::Object(mut problem)) =
serde_json::from_slice::<serde_json::Value>(&self.body)
{
problem.insert(
"requestId".to_string(),
serde_json::Value::String(request_id.to_string()),
);
let mut body = serde_json::to_vec(&problem).expect("serialize problem body");
body.push(b'\n');
self.body = body;
}
}
pub(super) fn problem(status: &'static str, body: Vec<u8>) -> Self {
Self {
status,
content_type: Some("application/problem+json"),
headers: Vec::new(),
body,
}
}
pub(super) fn binary_with_headers(
status: &'static str,
content_type: &'static str,
headers: Vec<(String, String)>,
body: Vec<u8>,
) -> Self {
Self {
status,
content_type: Some(content_type),
headers,
body,
}
}
pub(super) fn text(status: &'static str, content_type: &'static str, body: Vec<u8>) -> Self {
Self {
status,
content_type: Some(content_type),
headers: Vec::new(),
body,
}
}
pub(super) fn empty(status: &'static str, headers: Vec<(String, String)>) -> Self {
Self {
status,
content_type: None,
headers,
body: Vec::new(),
}
}
}
const MIN_COMPRESS_BYTES: usize = 128;
fn is_compressible_content_type(ct: &str) -> bool {
let media_type = ct.split(';').next().unwrap_or("").trim();
matches!(
media_type,
"application/json"
| "application/problem+json"
| "text/plain"
| "application/openmetrics-text"
)
}
pub(super) fn write_response(
stream: &mut TcpStream,
response: HttpResponse,
close: bool,
) -> io::Result<()> {
write_response_compressed(stream, response, close, false, 1)
}
pub(super) fn write_response_compressed(
stream: &mut TcpStream,
response: HttpResponse,
close: bool,
accepts_gzip: bool,
compression_level: u32,
) -> io::Result<()> {
use std::fmt::Write as FmtWrite;
let should_compress = accepts_gzip
&& response.body.len() >= MIN_COMPRESS_BYTES
&& response
.content_type
.is_some_and(is_compressible_content_type);
let (body, is_compressed) = if should_compress {
match gzip_compress(&response.body, compression_level) {
Ok(compressed) if compressed.len() < response.body.len() => (compressed, true),
_ => (response.body, false),
}
} else {
(response.body, false)
};
let connection_value = if close { "close" } else { "keep-alive" };
let mut header = format!(
"HTTP/1.1 {}\r\nContent-Length: {}\r\nConnection: {connection_value}\r\n",
response.status,
body.len()
);
if let Some(content_type) = response.content_type {
let _ = write!(header, "Content-Type: {content_type}\r\n");
}
if is_compressed {
header.push_str("Content-Encoding: gzip\r\n");
}
let mut vary_parts: Vec<&str> = Vec::new();
if accepts_gzip
&& response
.content_type
.is_some_and(is_compressible_content_type)
{
vary_parts.push("Accept-Encoding");
}
for (name, value) in &response.headers {
if name.eq_ignore_ascii_case("Vary") {
for part in value.split(',') {
let trimmed = part.trim();
if !trimmed.is_empty()
&& !vary_parts.iter().any(|v| v.eq_ignore_ascii_case(trimmed))
{
vary_parts.push(trimmed);
}
}
}
}
if !vary_parts.is_empty() {
let _ = write!(header, "Vary: {}\r\n", vary_parts.join(", "));
}
for (name, value) in response.headers {
if !name.eq_ignore_ascii_case("Vary") {
let _ = write!(header, "{name}: {value}\r\n");
}
}
header.push_str("\r\n");
stream.write_all(header.as_bytes())?;
stream.write_all(&body)?;
stream.flush()
}
fn gzip_compress(data: &[u8], level: u32) -> io::Result<Vec<u8>> {
use flate2::Compression;
use flate2::write::GzEncoder;
let mut encoder = GzEncoder::new(Vec::new(), Compression::new(level));
encoder.write_all(data)?;
encoder.finish()
}
pub(super) const WARNING_HEADER: &str = "Truss-Warning";
pub(super) fn warning_header_value(text: &str) -> String {
text.chars()
.map(|c| match c {
'\t' | '\r' | '\n' => ' ',
' '..='~' => c,
_ => '?',
})
.collect()
}
pub(super) fn push_warning_headers(headers: &mut Vec<(String, String)>, warnings: &[String]) {
for warning in warnings {
headers.push((WARNING_HEADER.to_string(), warning_header_value(warning)));
}
}
pub(super) const PROBLEM_TYPES_URL: &str =
"https://github.com/nao1215/truss/blob/main/docs/problems.md";
impl ErrorClass {
pub(super) const fn title(self) -> &'static str {
match self {
Self::InvalidRequest => "Invalid request",
Self::InvalidOptions => "Invalid transform options",
Self::InvalidInput => "Invalid input",
Self::DecodeFailed => "Input could not be decoded",
Self::UnsupportedMediaType => "Unsupported Media Type",
Self::UnsupportedInputMediaType => "Unsupported input media type",
Self::UnsupportedOutputMediaType => "Unsupported output media type",
Self::EncodeFailed => "Output could not be encoded",
Self::CapabilityMissing => "Capability not available",
Self::LimitExceeded => "Limit exceeded",
Self::Unauthorized => "Unauthorized",
Self::Forbidden => "Forbidden",
Self::NotFound => "Not Found",
Self::NotAcceptable => "Not Acceptable",
Self::RequestTimeout => "Request Timeout",
Self::PayloadTooLarge => "Payload Too Large",
Self::UnprocessableEntity => "Unprocessable Entity",
Self::TooManyRequests => "Too Many Requests",
Self::InternalError => "Internal Server Error",
Self::NotImplemented => "Not Implemented",
Self::BadGateway => "Bad Gateway",
Self::ServiceUnavailable => "Service Unavailable",
Self::LoopDetected => "Loop Detected",
}
}
pub(super) const fn status(self) -> (&'static str, u16) {
match self {
Self::InvalidRequest
| Self::InvalidOptions
| Self::InvalidInput
| Self::DecodeFailed => ("400 Bad Request", 400),
Self::Unauthorized => ("401 Unauthorized", 401),
Self::Forbidden => ("403 Forbidden", 403),
Self::NotFound => ("404 Not Found", 404),
Self::NotAcceptable => ("406 Not Acceptable", 406),
Self::RequestTimeout => ("408 Request Timeout", 408),
Self::PayloadTooLarge | Self::LimitExceeded => ("413 Payload Too Large", 413),
Self::UnsupportedMediaType
| Self::UnsupportedInputMediaType
| Self::UnsupportedOutputMediaType => ("415 Unsupported Media Type", 415),
Self::UnprocessableEntity => ("422 Unprocessable Entity", 422),
Self::TooManyRequests => ("429 Too Many Requests", 429),
Self::InternalError | Self::EncodeFailed => ("500 Internal Server Error", 500),
Self::NotImplemented | Self::CapabilityMissing => ("501 Not Implemented", 501),
Self::BadGateway => ("502 Bad Gateway", 502),
Self::ServiceUnavailable => ("503 Service Unavailable", 503),
Self::LoopDetected => ("508 Loop Detected", 508),
}
}
pub(super) fn uri(self) -> String {
format!("{PROBLEM_TYPES_URL}#{}", self.slug())
}
}
pub(super) fn bad_request_response(message: &str) -> HttpResponse {
problem_response(ErrorClass::InvalidRequest, message)
}
pub(super) fn auth_required_response(message: &str) -> HttpResponse {
let mut response = problem_response(ErrorClass::Unauthorized, message);
response
.headers
.push(("WWW-Authenticate".to_string(), "Bearer".to_string()));
response
}
pub(super) fn signed_url_unauthorized_response(message: &str) -> HttpResponse {
problem_response(ErrorClass::Unauthorized, message)
}
pub(super) fn not_found_response(message: &str) -> HttpResponse {
problem_response(ErrorClass::NotFound, message)
}
pub(super) fn forbidden_response(message: &str) -> HttpResponse {
problem_response(ErrorClass::Forbidden, message)
}
pub(super) fn unsupported_media_type_response(message: &str) -> HttpResponse {
problem_response(ErrorClass::UnsupportedMediaType, message)
}
pub(super) fn unsupported_output_media_type_response(message: &str) -> HttpResponse {
problem_response(ErrorClass::UnsupportedOutputMediaType, message)
}
pub(super) fn not_acceptable_response(message: &str) -> HttpResponse {
problem_response(ErrorClass::NotAcceptable, message)
}
pub(super) fn unprocessable_entity_response(message: &str) -> HttpResponse {
problem_response(ErrorClass::UnprocessableEntity, message)
}
pub(super) fn payload_too_large_response(message: &str) -> HttpResponse {
problem_response(ErrorClass::PayloadTooLarge, message)
}
pub(super) fn request_timeout_response(message: &str) -> HttpResponse {
problem_response(ErrorClass::RequestTimeout, message)
}
pub(super) fn internal_error_response(message: &str) -> HttpResponse {
problem_response(ErrorClass::InternalError, message)
}
pub(super) fn bad_gateway_response(message: &str) -> HttpResponse {
problem_response(ErrorClass::BadGateway, message)
}
pub(super) fn service_unavailable_response(message: &str) -> HttpResponse {
problem_response(ErrorClass::ServiceUnavailable, message)
}
pub(super) fn too_many_requests_response(message: &str) -> HttpResponse {
let mut resp = problem_response(ErrorClass::TooManyRequests, message);
resp.headers
.push(("Retry-After".to_string(), "1".to_string()));
resp
}
pub(super) fn too_many_redirects_response(message: &str) -> HttpResponse {
problem_response(ErrorClass::LoopDetected, message)
}
pub(super) fn not_implemented_response(message: &str) -> HttpResponse {
problem_response(ErrorClass::NotImplemented, message)
}
pub(super) fn problem_response(class: ErrorClass, detail: &str) -> HttpResponse {
let (status_line, _) = class.status();
HttpResponse::problem(status_line, problem_detail_body(class, detail))
}
pub(super) fn problem_detail_body(class: ErrorClass, detail: &str) -> Vec<u8> {
let (_, status) = class.status();
let mut body = serde_json::to_vec(&json!({
"type": class.uri(),
"title": class.title(),
"status": status,
"detail": detail,
}))
.expect("serialize problem detail body");
body.push(b'\n');
body
}
pub(super) fn transform_error_response(error: TransformError) -> HttpResponse {
let class = error.class();
let detail = match error {
TransformError::EncodeFailed(reason) => {
format!("failed to encode transformed artifact: {reason}")
}
TransformError::InvalidOptions(reason)
| TransformError::InvalidInput(reason)
| TransformError::DecodeFailed(reason)
| TransformError::UnsupportedInputMediaType(reason)
| TransformError::CapabilityMissing(reason)
| TransformError::LimitExceeded(reason) => reason,
ref error @ TransformError::UnsupportedOutputMediaType(_) => error.to_string(),
};
problem_response(class, &detail)
}
pub(super) fn map_source_io_error(error: io::Error) -> HttpResponse {
match error.kind() {
io::ErrorKind::NotFound => not_found_response("source artifact was not found"),
_ => internal_error_response(&format!("failed to access source artifact: {error}")),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{MediaType, TransformError};
use serde_json::Value;
fn parse_body(response: &HttpResponse) -> Value {
serde_json::from_slice(&response.body).expect("body should be valid JSON")
}
#[test]
fn test_problem_detail_body_contains_required_fields() {
let body = problem_detail_body(ErrorClass::NotFound, "resource missing");
let v: Value = serde_json::from_slice(&body).expect("valid JSON");
assert_eq!(v["type"], format!("{PROBLEM_TYPES_URL}#not-found"));
assert_eq!(v["title"], "Not Found");
assert_eq!(v["status"], 404);
assert_eq!(v["detail"], "resource missing");
}
#[test]
fn test_problem_detail_body_ends_with_newline() {
let body = problem_detail_body(ErrorClass::InternalError, "boom");
assert_eq!(*body.last().unwrap(), b'\n');
}
#[test]
fn test_problem_detail_body_special_characters_in_detail() {
let body = problem_detail_body(
ErrorClass::InvalidRequest,
"invalid <script>alert(1)</script>",
);
let v: Value = serde_json::from_slice(&body).expect("valid JSON");
assert_eq!(v["detail"], "invalid <script>alert(1)</script>");
}
#[test]
fn test_bad_request_response_status_and_content_type() {
let resp = bad_request_response("missing parameter");
assert_eq!(resp.status, "400 Bad Request");
assert_eq!(resp.content_type, Some("application/problem+json"));
let v = parse_body(&resp);
assert_eq!(v["status"], 400);
assert_eq!(v["title"], "Invalid request");
assert_eq!(v["detail"], "missing parameter");
}
#[test]
fn test_not_found_response_status_and_body() {
let resp = not_found_response("image not found");
assert_eq!(resp.status, "404 Not Found");
assert_eq!(resp.content_type, Some("application/problem+json"));
let v = parse_body(&resp);
assert_eq!(v["status"], 404);
assert_eq!(v["title"], "Not Found");
assert_eq!(v["detail"], "image not found");
}
#[test]
fn test_internal_error_response_status_and_body() {
let resp = internal_error_response("disk full");
assert_eq!(resp.status, "500 Internal Server Error");
let v = parse_body(&resp);
assert_eq!(v["status"], 500);
assert_eq!(v["title"], "Internal Server Error");
assert_eq!(v["detail"], "disk full");
}
#[test]
fn test_forbidden_response_status_and_body() {
let resp = forbidden_response("access denied");
assert_eq!(resp.status, "403 Forbidden");
let v = parse_body(&resp);
assert_eq!(v["status"], 403);
assert_eq!(v["title"], "Forbidden");
assert_eq!(v["detail"], "access denied");
}
#[test]
fn test_unsupported_media_type_response() {
let resp = unsupported_media_type_response("image/gif is not supported");
assert_eq!(resp.status, "415 Unsupported Media Type");
let v = parse_body(&resp);
assert_eq!(v["status"], 415);
assert_eq!(v["title"], "Unsupported Media Type");
}
#[test]
fn test_not_acceptable_response() {
let resp = not_acceptable_response("no acceptable format");
assert_eq!(resp.status, "406 Not Acceptable");
let v = parse_body(&resp);
assert_eq!(v["status"], 406);
assert_eq!(v["title"], "Not Acceptable");
}
#[test]
fn test_payload_too_large_response() {
let resp = payload_too_large_response("exceeds 10MB limit");
assert_eq!(resp.status, "413 Payload Too Large");
let v = parse_body(&resp);
assert_eq!(v["status"], 413);
assert_eq!(v["title"], "Payload Too Large");
assert_eq!(v["detail"], "exceeds 10MB limit");
}
#[test]
fn test_bad_gateway_response() {
let resp = bad_gateway_response("upstream error");
assert_eq!(resp.status, "502 Bad Gateway");
let v = parse_body(&resp);
assert_eq!(v["status"], 502);
assert_eq!(v["title"], "Bad Gateway");
}
#[test]
fn test_service_unavailable_response() {
let resp = service_unavailable_response("overloaded");
assert_eq!(resp.status, "503 Service Unavailable");
let v = parse_body(&resp);
assert_eq!(v["status"], 503);
assert_eq!(v["title"], "Service Unavailable");
}
#[test]
fn test_too_many_requests_response_includes_retry_after() {
let resp = too_many_requests_response("rate limit exceeded");
assert_eq!(resp.status, "429 Too Many Requests");
let v = parse_body(&resp);
assert_eq!(v["status"], 429);
assert_eq!(v["title"], "Too Many Requests");
let retry_after = resp.headers.iter().find(|(name, _)| name == "Retry-After");
assert_eq!(retry_after.map(|(_, v)| v.as_str()), Some("1"));
}
#[test]
fn test_too_many_redirects_response() {
let resp = too_many_redirects_response("redirect loop");
assert_eq!(resp.status, "508 Loop Detected");
let v = parse_body(&resp);
assert_eq!(v["status"], 508);
assert_eq!(v["title"], "Loop Detected");
}
#[test]
fn test_not_implemented_response() {
let resp = not_implemented_response("feature unavailable");
assert_eq!(resp.status, "501 Not Implemented");
let v = parse_body(&resp);
assert_eq!(v["status"], 501);
assert_eq!(v["title"], "Not Implemented");
assert_eq!(v["detail"], "feature unavailable");
}
#[test]
fn test_signed_url_unauthorized_response_no_www_authenticate() {
let resp = signed_url_unauthorized_response("bad signature");
assert_eq!(resp.status, "401 Unauthorized");
assert_eq!(resp.content_type, Some("application/problem+json"));
assert!(resp.headers.is_empty());
let v = parse_body(&resp);
assert_eq!(v["status"], 401);
assert_eq!(v["detail"], "bad signature");
}
#[test]
fn test_auth_required_response_includes_www_authenticate_header() {
let resp = auth_required_response("token required");
assert_eq!(resp.status, "401 Unauthorized");
assert_eq!(resp.content_type, Some("application/problem+json"));
let www_auth = resp
.headers
.iter()
.find(|(name, _)| *name == "WWW-Authenticate");
assert!(www_auth.is_some(), "must include WWW-Authenticate header");
assert_eq!(www_auth.unwrap().1, "Bearer");
let v = parse_body(&resp);
assert_eq!(v["status"], 401);
assert_eq!(v["title"], "Unauthorized");
assert_eq!(v["detail"], "token required");
}
#[test]
fn test_transform_error_response_invalid_input() {
let resp = transform_error_response(TransformError::InvalidInput("bad input".into()));
assert_eq!(resp.status, "400 Bad Request");
let v = parse_body(&resp);
assert_eq!(v["detail"], "bad input");
}
#[test]
fn test_transform_error_response_invalid_options() {
let resp = transform_error_response(TransformError::InvalidOptions("bad opts".into()));
assert_eq!(resp.status, "400 Bad Request");
let v = parse_body(&resp);
assert_eq!(v["detail"], "bad opts");
}
#[test]
fn test_transform_error_response_decode_failed() {
let resp = transform_error_response(TransformError::DecodeFailed("corrupt".into()));
assert_eq!(resp.status, "400 Bad Request");
let v = parse_body(&resp);
assert_eq!(v["detail"], "corrupt");
}
#[test]
fn test_transform_error_response_unsupported_input_media_type() {
let resp = transform_error_response(TransformError::UnsupportedInputMediaType(
"image/gif".into(),
));
assert_eq!(resp.status, "415 Unsupported Media Type");
let v = parse_body(&resp);
assert_eq!(v["detail"], "image/gif");
}
#[test]
fn test_transform_error_response_unsupported_output_media_type() {
let resp =
transform_error_response(TransformError::UnsupportedOutputMediaType(MediaType::Gif));
assert_eq!(resp.status, "415 Unsupported Media Type");
let v = parse_body(&resp);
assert_eq!(
v["detail"],
"gif is an input-only format; choose an output format such as png, jpeg, webp, or avif"
);
let resp =
transform_error_response(TransformError::UnsupportedOutputMediaType(MediaType::Svg));
assert_eq!(resp.status, "415 Unsupported Media Type");
let v = parse_body(&resp);
assert_eq!(
v["detail"],
"svg output requires an svg input; choose a raster output format such as png, jpeg, webp, or avif"
);
}
#[test]
fn test_transform_error_response_encode_failed() {
let resp = transform_error_response(TransformError::EncodeFailed("out of memory".into()));
assert_eq!(resp.status, "500 Internal Server Error");
let v = parse_body(&resp);
assert_eq!(
v["detail"],
"failed to encode transformed artifact: out of memory"
);
}
#[test]
fn test_transform_error_response_capability_missing() {
let resp = transform_error_response(TransformError::CapabilityMissing(
"AVIF not compiled".into(),
));
assert_eq!(resp.status, "501 Not Implemented");
let v = parse_body(&resp);
assert_eq!(v["detail"], "AVIF not compiled");
}
#[test]
fn test_transform_error_response_limit_exceeded() {
let resp = transform_error_response(TransformError::LimitExceeded("too large".into()));
assert_eq!(resp.status, "413 Payload Too Large");
let v = parse_body(&resp);
assert_eq!(v["detail"], "too large");
}
#[test]
fn test_map_source_io_error_not_found() {
let err = io::Error::new(io::ErrorKind::NotFound, "no such file");
let resp = map_source_io_error(err);
assert_eq!(resp.status, "404 Not Found");
let v = parse_body(&resp);
assert_eq!(v["detail"], "source artifact was not found");
}
#[test]
fn test_map_source_io_error_permission_denied() {
let err = io::Error::new(io::ErrorKind::PermissionDenied, "forbidden");
let resp = map_source_io_error(err);
assert_eq!(resp.status, "500 Internal Server Error");
let v = parse_body(&resp);
let detail = v["detail"].as_str().unwrap();
assert!(
detail.starts_with("failed to access source artifact:"),
"detail should describe the IO error, got: {detail}"
);
}
#[test]
fn test_map_source_io_error_other() {
let err = io::Error::new(io::ErrorKind::ConnectionRefused, "refused");
let resp = map_source_io_error(err);
assert_eq!(resp.status, "500 Internal Server Error");
}
#[test]
fn test_not_found_body_is_valid_rfc7807_json() {
let v: Value = serde_json::from_str(NOT_FOUND_BODY).expect("NOT_FOUND_BODY is valid JSON");
assert_eq!(v["type"], "about:blank");
assert_eq!(v["title"], "Not Found");
assert_eq!(v["status"], 404);
assert_eq!(v["detail"], "not found");
}
#[test]
fn test_http_response_json_constructor() {
let resp = HttpResponse::json("200 OK", b"{}".to_vec());
assert_eq!(resp.status, "200 OK");
assert_eq!(resp.content_type, Some("application/json"));
assert!(resp.headers.is_empty());
assert_eq!(resp.body, b"{}");
}
#[test]
fn test_http_response_problem_constructor() {
let resp = HttpResponse::problem("400 Bad Request", b"err".to_vec());
assert_eq!(resp.content_type, Some("application/problem+json"));
assert!(resp.headers.is_empty());
}
#[test]
fn test_http_response_empty_constructor() {
let headers = vec![("X-Custom".to_string(), "val".to_string())];
let resp = HttpResponse::empty("204 No Content", headers);
assert_eq!(resp.status, "204 No Content");
assert!(resp.content_type.is_none());
assert!(resp.body.is_empty());
assert_eq!(resp.headers.len(), 1);
}
#[test]
fn test_http_response_text_constructor() {
let resp = HttpResponse::text("200 OK", "text/plain", b"hello".to_vec());
assert_eq!(resp.content_type, Some("text/plain"));
assert_eq!(resp.body, b"hello");
}
#[test]
fn test_http_response_binary_with_headers_constructor() {
let headers = vec![("Cache-Control".to_string(), "no-cache".to_string())];
let resp =
HttpResponse::binary_with_headers("200 OK", "image/png", headers, vec![0x89, 0x50]);
assert_eq!(resp.content_type, Some("image/png"));
assert_eq!(resp.headers.len(), 1);
assert_eq!(resp.body, vec![0x89, 0x50]);
}
#[test]
fn every_problem_response_names_its_class() {
let responses = vec![
(
bad_request_response("x"),
"invalid-request",
"Invalid request",
400,
),
(
auth_required_response("x"),
"unauthorized",
"Unauthorized",
401,
),
(
signed_url_unauthorized_response("x"),
"unauthorized",
"Unauthorized",
401,
),
(forbidden_response("x"), "forbidden", "Forbidden", 403),
(not_found_response("x"), "not-found", "Not Found", 404),
(
not_acceptable_response("x"),
"not-acceptable",
"Not Acceptable",
406,
),
(
request_timeout_response("x"),
"request-timeout",
"Request Timeout",
408,
),
(
payload_too_large_response("x"),
"payload-too-large",
"Payload Too Large",
413,
),
(
unsupported_media_type_response("x"),
"unsupported-media-type",
"Unsupported Media Type",
415,
),
(
unprocessable_entity_response("x"),
"unprocessable-entity",
"Unprocessable Entity",
422,
),
(
too_many_requests_response("x"),
"too-many-requests",
"Too Many Requests",
429,
),
(
internal_error_response("x"),
"internal-error",
"Internal Server Error",
500,
),
(
not_implemented_response("x"),
"not-implemented",
"Not Implemented",
501,
),
(bad_gateway_response("x"), "bad-gateway", "Bad Gateway", 502),
(
service_unavailable_response("x"),
"service-unavailable",
"Service Unavailable",
503,
),
(
too_many_redirects_response("x"),
"loop-detected",
"Loop Detected",
508,
),
];
for (resp, slug, title, status) in &responses {
assert_eq!(
resp.content_type,
Some("application/problem+json"),
"{slug}"
);
let v = parse_body(resp);
assert_eq!(v["type"], format!("{PROBLEM_TYPES_URL}#{slug}"), "{slug}");
assert_eq!(v["title"], *title, "{slug}");
assert_eq!(v["status"], *status, "{slug}");
assert_eq!(v["detail"], "x", "{slug}");
}
}
#[test]
fn transform_errors_map_onto_their_own_problem_types() {
let cases = vec![
(
TransformError::InvalidOptions("o".into()),
"invalid-options",
"400 Bad Request",
),
(
TransformError::InvalidInput("i".into()),
"invalid-input",
"400 Bad Request",
),
(
TransformError::DecodeFailed("d".into()),
"decode-failed",
"400 Bad Request",
),
(
TransformError::UnsupportedInputMediaType("u".into()),
"unsupported-input-media-type",
"415 Unsupported Media Type",
),
(
TransformError::UnsupportedOutputMediaType(MediaType::Gif),
"unsupported-output-media-type",
"415 Unsupported Media Type",
),
(
TransformError::EncodeFailed("e".into()),
"encode-failed",
"500 Internal Server Error",
),
(
TransformError::CapabilityMissing("c".into()),
"capability-missing",
"501 Not Implemented",
),
(
TransformError::LimitExceeded("l".into()),
"limit-exceeded",
"413 Payload Too Large",
),
];
for (error, slug, status) in cases {
assert_eq!(error.class().slug(), slug, "{slug}");
let resp = transform_error_response(error);
assert_eq!(resp.status, status, "{slug}");
let v = parse_body(&resp);
assert_eq!(v["type"], format!("{PROBLEM_TYPES_URL}#{slug}"), "{slug}");
assert_ne!(v["title"], "", "{slug}");
}
}
#[test]
fn only_the_unknown_route_keeps_about_blank() {
let v: Value = serde_json::from_str(NOT_FOUND_BODY).expect("valid JSON");
assert_eq!(v["type"], "about:blank");
}
#[test]
fn warning_header_value_is_one_line_of_visible_ascii() {
assert_eq!(warning_header_value("plain text"), "plain text");
assert_eq!(warning_header_value("a\tb\r\nc"), "a b c");
assert_eq!(warning_header_value("caf\u{e9} \u{7f}"), "caf? ?");
let mut headers = Vec::new();
push_warning_headers(&mut headers, &["one".to_string(), "two".to_string()]);
assert_eq!(
headers,
vec![
(WARNING_HEADER.to_string(), "one".to_string()),
(WARNING_HEADER.to_string(), "two".to_string()),
]
);
}
#[test]
fn attach_request_id_sets_the_header_and_the_problem_member() {
let mut problem = bad_request_response("x");
problem.attach_request_id("req-1");
assert!(
problem
.headers
.contains(&("X-Request-Id".to_string(), "req-1".to_string()))
);
let v = parse_body(&problem);
assert_eq!(v["requestId"], "req-1");
assert_eq!(v["type"], format!("{PROBLEM_TYPES_URL}#invalid-request"));
assert!(problem.body.ends_with(b"\n"));
let mut image = HttpResponse::json("200 OK", b"{}".to_vec());
image.attach_request_id("req-2");
assert!(
image
.headers
.contains(&("X-Request-Id".to_string(), "req-2".to_string()))
);
assert_eq!(
image.body,
b"{}".to_vec(),
"only a problem body gains the member"
);
}
#[test]
fn test_is_compressible_json() {
assert!(is_compressible_content_type("application/json"));
assert!(is_compressible_content_type("application/problem+json"));
}
#[test]
fn test_is_not_compressible_image() {
assert!(!is_compressible_content_type("image/png"));
assert!(!is_compressible_content_type("image/jpeg"));
assert!(!is_compressible_content_type("image/webp"));
}
#[test]
fn test_is_compressible_text() {
assert!(is_compressible_content_type("text/plain"));
assert!(is_compressible_content_type(
"application/openmetrics-text; version=1.0.0; charset=utf-8"
));
}
#[test]
fn test_gzip_compress_roundtrip() {
use flate2::read::GzDecoder;
use std::io::Read;
let original = b"hello world, this is test data that should compress well. \
repeating repeating repeating repeating repeating repeating.";
let compressed = gzip_compress(original, 1).unwrap();
assert!(compressed.len() < original.len());
let mut decoder = GzDecoder::new(&compressed[..]);
let mut decompressed = Vec::new();
decoder.read_to_end(&mut decompressed).unwrap();
assert_eq!(decompressed, original);
}
#[test]
fn test_not_compressible_unknown_type() {
assert!(!is_compressible_content_type("application/octet-stream"));
assert!(!is_compressible_content_type("video/mp4"));
}
#[test]
fn test_gzip_compress_below_threshold_skipped() {
let body = vec![b'x'; MIN_COMPRESS_BYTES - 1];
let response = HttpResponse::json("200 OK", body.clone());
let mut _stream_buf: Vec<u8> = Vec::new();
let should_compress = response.body.len() >= MIN_COMPRESS_BYTES
&& response
.content_type
.is_some_and(is_compressible_content_type);
assert!(
!should_compress,
"body below threshold should not be compressed"
);
}
#[test]
fn test_gzip_compress_at_threshold_eligible() {
let body = vec![b'x'; MIN_COMPRESS_BYTES];
let response = HttpResponse::json("200 OK", body);
let should_compress = response.body.len() >= MIN_COMPRESS_BYTES
&& response
.content_type
.is_some_and(is_compressible_content_type);
assert!(
should_compress,
"body at threshold should be eligible for compression"
);
}
#[test]
fn test_gzip_compress_above_threshold_eligible() {
let body = vec![b'x'; MIN_COMPRESS_BYTES + 1];
let response = HttpResponse::json("200 OK", body);
let should_compress = response.body.len() >= MIN_COMPRESS_BYTES
&& response
.content_type
.is_some_and(is_compressible_content_type);
assert!(
should_compress,
"body above threshold should be eligible for compression"
);
}
#[cfg(unix)]
fn capture_response(response: HttpResponse, accepts_gzip: bool) -> Vec<u8> {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let mut client = std::net::TcpStream::connect(addr).unwrap();
let (mut server_stream, _) = listener.accept().unwrap();
write_response_compressed(&mut server_stream, response, true, accepts_gzip, 1).unwrap();
drop(server_stream);
let mut buf = Vec::new();
std::io::Read::read_to_end(&mut client, &mut buf).unwrap();
buf
}
#[cfg(unix)]
#[test]
fn test_write_response_compressed_applies_gzip() {
use flate2::read::GzDecoder;
use std::io::Read;
let body = format!("{{\"data\":\"{}\"}}", "x".repeat(256));
let response = HttpResponse::json("200 OK", body.as_bytes().to_vec());
let raw = capture_response(response, true);
let raw_str = String::from_utf8_lossy(&raw);
assert!(
raw_str.contains("Content-Encoding: gzip"),
"should contain Content-Encoding: gzip"
);
assert!(
raw_str.contains("Vary: Accept-Encoding"),
"should contain Vary header"
);
let body_start = raw_str.find("\r\n\r\n").unwrap() + 4;
let compressed_body = &raw[body_start..];
let mut decoder = GzDecoder::new(compressed_body);
let mut decompressed = String::new();
decoder.read_to_string(&mut decompressed).unwrap();
assert_eq!(decompressed, body);
}
#[cfg(unix)]
#[test]
fn test_write_response_compressed_skips_when_not_accepted() {
let body = format!("{{\"data\":\"{}\"}}", "x".repeat(256));
let response = HttpResponse::json("200 OK", body.as_bytes().to_vec());
let raw = capture_response(response, false);
let raw_str = String::from_utf8_lossy(&raw);
assert!(
!raw_str.contains("Content-Encoding: gzip"),
"should NOT contain Content-Encoding: gzip"
);
}
#[cfg(unix)]
#[test]
fn test_write_response_compressed_skips_small_body() {
let body = b"{\"ok\":true}".to_vec();
let response = HttpResponse::json("200 OK", body);
let raw = capture_response(response, true);
let raw_str = String::from_utf8_lossy(&raw);
assert!(
!raw_str.contains("Content-Encoding: gzip"),
"small body should not be compressed"
);
assert!(
raw_str.contains("Vary: Accept-Encoding"),
"Vary should be present for compressible type"
);
}
}