use crate::error::{Result, UltimoError};
use http_body_util::combinators::UnsyncBoxBody;
use http_body_util::Full;
use hyper::body::{Body as HttpBody, Bytes, Frame, SizeHint};
use hyper::{header::HeaderValue, Response as HyperResponse, StatusCode};
use serde::Serialize;
use std::collections::HashMap;
use std::pin::Pin;
use std::task::{Context as TaskContext, Poll};
pub type BoxError = Box<dyn std::error::Error + Send + Sync>;
pub enum UltimoBody {
Full(Full<Bytes>),
Stream(UnsyncBoxBody<Bytes, BoxError>),
}
impl UltimoBody {
pub fn empty() -> Self {
UltimoBody::Full(Full::new(Bytes::new()))
}
pub fn full(bytes: impl Into<Bytes>) -> Self {
UltimoBody::Full(Full::new(bytes.into()))
}
pub fn stream<S>(stream: S) -> Self
where
S: futures_util::Stream<Item = std::result::Result<Bytes, BoxError>> + Send + 'static,
{
use futures_util::TryStreamExt;
use http_body_util::{BodyExt, StreamBody};
let body = StreamBody::new(stream.map_ok(Frame::data));
UltimoBody::Stream(body.boxed_unsync())
}
}
impl HttpBody for UltimoBody {
type Data = Bytes;
type Error = BoxError;
fn poll_frame(
self: Pin<&mut Self>,
cx: &mut TaskContext<'_>,
) -> Poll<Option<std::result::Result<Frame<Self::Data>, Self::Error>>> {
match self.get_mut() {
UltimoBody::Full(f) => match Pin::new(f).poll_frame(cx) {
Poll::Ready(Some(Ok(frame))) => Poll::Ready(Some(Ok(frame))),
Poll::Ready(Some(Err(e))) => Poll::Ready(Some(Err(Box::new(e) as BoxError))),
Poll::Ready(None) => Poll::Ready(None),
Poll::Pending => Poll::Pending,
},
UltimoBody::Stream(s) => Pin::new(s).poll_frame(cx),
}
}
fn is_end_stream(&self) -> bool {
match self {
UltimoBody::Full(f) => f.is_end_stream(),
UltimoBody::Stream(s) => s.is_end_stream(),
}
}
fn size_hint(&self) -> SizeHint {
match self {
UltimoBody::Full(f) => f.size_hint(),
UltimoBody::Stream(s) => s.size_hint(),
}
}
}
pub type Response = HyperResponse<UltimoBody>;
#[derive(Debug)]
pub struct ResponseBuilder {
status: StatusCode,
headers: HashMap<String, String>,
body: Option<Vec<u8>>,
}
impl ResponseBuilder {
pub fn new() -> Self {
Self {
status: StatusCode::OK,
headers: HashMap::new(),
body: None,
}
}
pub fn status(mut self, status: u16) -> Self {
self.status = StatusCode::from_u16(status).unwrap_or(StatusCode::OK);
self
}
pub fn header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
self.headers.insert(name.into(), value.into());
self
}
pub fn body(mut self, body: impl Into<Vec<u8>>) -> Self {
self.body = Some(body.into());
self
}
pub fn json<T: Serialize>(self, value: &T) -> Result<Self> {
let json = serde_json::to_vec(value)?;
Ok(self.header("Content-Type", "application/json").body(json))
}
pub fn text(self, text: impl Into<String>) -> Self {
let text = text.into();
self.header("Content-Type", "text/plain; charset=utf-8")
.body(text.into_bytes())
}
pub fn html(self, html: impl Into<String>) -> Self {
let html = html.into();
self.header("Content-Type", "text/html; charset=utf-8")
.body(html.into_bytes())
}
pub fn build(self) -> Result<Response> {
let mut response = HyperResponse::builder().status(self.status);
for (name, value) in self.headers {
response = response.header(
name.as_str(),
HeaderValue::from_str(&value)
.map_err(|_| UltimoError::Internal("Invalid header value".to_string()))?,
);
}
let body = self.body.unwrap_or_default();
response
.body(UltimoBody::full(body))
.map_err(|e| UltimoError::Internal(format!("Failed to build response: {}", e)))
}
}
impl Default for ResponseBuilder {
fn default() -> Self {
Self::new()
}
}
pub mod helpers {
use super::*;
pub fn json<T: Serialize>(value: &T) -> Result<Response> {
ResponseBuilder::new().json(value)?.build()
}
pub fn text(text: impl Into<String>) -> Result<Response> {
ResponseBuilder::new().text(text).build()
}
pub fn html(html: impl Into<String>) -> Result<Response> {
ResponseBuilder::new().html(html).build()
}
pub fn redirect(location: &str, status: Option<u16>) -> Result<Response> {
let status = status.unwrap_or(302);
ResponseBuilder::new()
.status(status)
.header("Location", location)
.build()
}
pub fn not_found() -> Result<Response> {
ResponseBuilder::new()
.status(404)
.json(&serde_json::json!({
"error": "NotFound",
"message": "The requested resource was not found"
}))?
.build()
}
pub fn error_response(error: &UltimoError) -> Result<Response> {
let status = error.status_code();
let body = error.to_error_response();
ResponseBuilder::new().status(status).json(&body)?.build()
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_json_response() {
let result = helpers::json(&json!({"message": "Hello"}));
assert!(result.is_ok());
}
#[test]
fn test_text_response() {
let result = helpers::text("Hello World");
assert!(result.is_ok());
}
#[test]
fn test_html_response() {
let result = helpers::html("<h1>Hello</h1>");
assert!(result.is_ok());
}
#[test]
fn test_redirect_response() {
let result = helpers::redirect("/login", Some(301));
assert!(result.is_ok());
let response = result.unwrap();
assert_eq!(response.status(), StatusCode::MOVED_PERMANENTLY);
}
#[test]
fn test_response_builder() {
let result = ResponseBuilder::new()
.status(201)
.header("X-Custom", "value")
.text("Created")
.build();
assert!(result.is_ok());
let response = result.unwrap();
assert_eq!(response.status(), StatusCode::CREATED);
}
#[tokio::test]
async fn ultimo_body_full_round_trips() {
use http_body_util::BodyExt;
let body = UltimoBody::full("hello world");
let bytes = body.collect().await.unwrap().to_bytes();
assert_eq!(&bytes[..], b"hello world");
}
#[tokio::test]
async fn ultimo_body_empty_is_zero_length() {
use http_body_util::BodyExt;
use hyper::body::Body as _;
let body = UltimoBody::empty();
assert!(body.is_end_stream());
let bytes = body.collect().await.unwrap().to_bytes();
assert_eq!(bytes.len(), 0);
}
#[tokio::test]
async fn ultimo_body_stream_concatenates_chunks() {
use http_body_util::BodyExt;
let chunks: Vec<std::result::Result<Bytes, BoxError>> = vec![
Ok(Bytes::from("foo")),
Ok(Bytes::from("bar")),
Ok(Bytes::from("baz")),
];
let body = UltimoBody::stream(futures_util::stream::iter(chunks));
let bytes = body.collect().await.unwrap().to_bytes();
assert_eq!(&bytes[..], b"foobarbaz");
}
}