Menu
Public repository / master
AkurAI-Framework / crates/http/src/response.rs
Latest change 69a2606c7ff3e89d2f50090fa59a15f14171a37a - feat: HTTP request bodies + form actions (guestbook demo, progressive enhancement) by Ólafur Búi Ólafsson
//! Building and serializing HTTP/1.1 responses.
//!
//! A [`Response`] is a plain value (status + headers + body); turning it into
//! wire bytes is pure, so it tests without a socket. The server module is the
//! only place that actually writes these to a stream.
/// An HTTP response ready to be serialized.
#[derive(Debug, Clone)]
pub struct Response {
pub status: u16,
pub reason: String,
pub headers: Vec<(String, String)>,
pub body: Vec<u8>,
}
impl Response {
/// A response with the given status, its canonical reason phrase, and an
/// empty body.
pub fn new(status: u16) -> Response {
Response {
status,
reason: reason_phrase(status).to_string(),
headers: Vec::new(),
body: Vec::new(),
}
}
pub fn ok() -> Response {
Response::new(200)
}
pub fn not_found() -> Response {
Response::new(404).with_text("404 Not Found")
}
pub fn bad_request() -> Response {
Response::new(400).with_text("400 Bad Request")
}
/// Set a header, replacing any existing header with the same (ASCII
/// case-insensitive) name.
pub fn with_header(mut self, name: &str, value: &str) -> Response {
self.headers.retain(|(k, _)| !k.eq_ignore_ascii_case(name));
self.headers.push((name.to_string(), value.to_string()));
self
}
/// Set the body and its `Content-Type`.
pub fn with_body(mut self, content_type: &str, body: Vec<u8>) -> Response {
self.body = body;
self.with_header("Content-Type", content_type)
}
pub fn with_text(self, text: &str) -> Response {
self.with_body("text/plain; charset=utf-8", text.as_bytes().to_vec())
}
pub fn with_html(self, html: &str) -> Response {
self.with_body("text/html; charset=utf-8", html.as_bytes().to_vec())
}
/// Serialize the full response (status line + headers + body) to bytes.
/// `Content-Length` is always emitted from the actual body length, so
/// callers cannot desync it.
pub fn to_bytes(&self) -> Vec<u8> {
let mut out = self.write_status_and_headers();
out.extend_from_slice(format!("Content-Length: {}\r\n\r\n", self.body.len()).as_bytes());
out.extend_from_slice(&self.body);
out
}
/// Serialize only the status line and headers, ending at the blank line —
/// no `Content-Length` and no body. This is what an upgraded connection
/// (WebSocket `101`, SSE `text/event-stream`) writes before the hijack takes
/// over the socket; the body is then streamed by the protocol itself.
pub fn to_head_bytes(&self) -> Vec<u8> {
let mut out = self.write_status_and_headers();
out.extend_from_slice(b"\r\n");
out
}
/// Status line + header lines, with NO terminating blank line. Both
/// serializers build on this and add their own tail.
fn write_status_and_headers(&self) -> Vec<u8> {
let mut out = Vec::with_capacity(64 + self.body.len());
out.extend_from_slice(format!("HTTP/1.1 {} {}\r\n", self.status, self.reason).as_bytes());
for (k, v) in &self.headers {
if k.eq_ignore_ascii_case("content-length") {
continue; // we own this header; ignore any caller-supplied one
}
out.extend_from_slice(format!("{k}: {v}\r\n").as_bytes());
}
out
}
}
/// Canonical reason phrases for the statuses we emit; unknown codes get a
/// generic phrase rather than failing.
fn reason_phrase(status: u16) -> &'static str {
match status {
101 => "Switching Protocols",
200 => "OK",
201 => "Created",
204 => "No Content",
301 => "Moved Permanently",
302 => "Found",
303 => "See Other",
304 => "Not Modified",
400 => "Bad Request",
401 => "Unauthorized",
403 => "Forbidden",
404 => "Not Found",
405 => "Method Not Allowed",
500 => "Internal Server Error",
_ => "Status",
}
}
#[cfg(test)]
mod tests {
use super::*;
fn as_str(r: &Response) -> String {
String::from_utf8(r.to_bytes()).unwrap()
}
#[test]
fn serializes_status_line_and_content_length() {
let r = Response::ok().with_text("hi");
let s = as_str(&r);
assert!(s.starts_with("HTTP/1.1 200 OK\r\n"), "got: {s:?}");
assert!(s.contains("Content-Type: text/plain; charset=utf-8\r\n"));
assert!(s.contains("Content-Length: 2\r\n"));
assert!(s.ends_with("\r\n\r\nhi"));
}
#[test]
fn with_header_replaces_case_insensitively() {
let r = Response::ok()
.with_header("X-A", "1")
.with_header("x-a", "2");
assert_eq!(
r.headers
.iter()
.filter(|(k, _)| k.eq_ignore_ascii_case("x-a"))
.count(),
1
);
assert_eq!(r.header_value("X-A"), Some("2"));
}
#[test]
fn caller_cannot_override_content_length() {
let r = Response::ok()
.with_header("Content-Length", "999")
.with_text("abc");
let s = as_str(&r);
assert!(s.contains("Content-Length: 3\r\n"));
assert!(!s.contains("999"));
}
#[test]
fn head_bytes_omit_content_length_and_body() {
let r = Response::new(101)
.with_header("Upgrade", "websocket")
.with_header("Connection", "Upgrade");
let s = String::from_utf8(r.to_head_bytes()).unwrap();
assert!(
s.starts_with("HTTP/1.1 101 Switching Protocols\r\n"),
"got: {s:?}"
);
assert!(s.contains("Upgrade: websocket\r\n"));
assert!(!s.to_ascii_lowercase().contains("content-length"));
assert!(s.ends_with("\r\n\r\n"));
}
#[test]
fn not_found_has_body_and_status() {
let s = as_str(&Response::not_found());
assert!(s.starts_with("HTTP/1.1 404 Not Found\r\n"));
assert!(s.ends_with("404 Not Found"));
}
impl Response {
fn header_value(&self, name: &str) -> Option<&str> {
self.headers
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case(name))
.map(|(_, v)| v.as_str())
}
}
}