ultimo 0.9.1

Modern Rust web framework with automatic TypeScript client generation
Documentation
//! HTTP response builder and utilities
//!
//! Internal response building that gets wrapped by Context methods.

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};

/// Boxed error carried by a streaming response body. A stream item that yields
/// `Err(_)` aborts the response connection.
pub type BoxError = Box<dyn std::error::Error + Send + Sync>;

/// The response body used throughout Ultimo.
///
/// `Full` is the buffered fast path — the overwhelmingly common case, with no
/// per-response boxing. `Stream` carries an incrementally produced body (see
/// [`Context::stream`](crate::context::Context::stream)).
pub enum UltimoBody {
    /// A fully-buffered body.
    Full(Full<Bytes>),
    /// A streaming body.
    Stream(UnsyncBoxBody<Bytes, BoxError>),
}

impl UltimoBody {
    /// An empty buffered body.
    pub fn empty() -> Self {
        UltimoBody::Full(Full::new(Bytes::new()))
    }

    /// A buffered body from any bytes-like value.
    pub fn full(bytes: impl Into<Bytes>) -> Self {
        UltimoBody::Full(Full::new(bytes.into()))
    }

    /// A streaming body produced from a `Stream` of byte chunks.
    ///
    /// Each `Ok(bytes)` becomes a data frame; an `Err(_)` aborts the connection.
    /// The response is sent chunked (no `Content-Length`).
    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() {
            // `Full`'s error is `Infallible`; box it to unify the error type.
            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(),
        }
    }
}

/// HTTP Response type used throughout Ultimo
pub type Response = HyperResponse<UltimoBody>;

/// Response builder for constructing HTTP responses
#[derive(Debug)]
pub struct ResponseBuilder {
    status: StatusCode,
    headers: HashMap<String, String>,
    body: Option<Vec<u8>>,
}

impl ResponseBuilder {
    /// Create a new response builder with 200 OK status
    pub fn new() -> Self {
        Self {
            status: StatusCode::OK,
            headers: HashMap::new(),
            body: None,
        }
    }

    /// Set the HTTP status code
    pub fn status(mut self, status: u16) -> Self {
        self.status = StatusCode::from_u16(status).unwrap_or(StatusCode::OK);
        self
    }

    /// Add a header to the response
    pub fn header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
        self.headers.insert(name.into(), value.into());
        self
    }

    /// Set the response body as bytes
    pub fn body(mut self, body: impl Into<Vec<u8>>) -> Self {
        self.body = Some(body.into());
        self
    }

    /// Set JSON response body and content-type
    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))
    }

    /// Set text response body and content-type
    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())
    }

    /// Set HTML response body and content-type
    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())
    }

    /// Build the final HTTP response
    pub fn build(self) -> Result<Response> {
        let mut response = HyperResponse::builder().status(self.status);

        // Add all headers
        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()))?,
            );
        }

        // Set body
        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()
    }
}

/// Helper functions for common responses
pub mod helpers {
    use super::*;

    /// Create a JSON response
    pub fn json<T: Serialize>(value: &T) -> Result<Response> {
        ResponseBuilder::new().json(value)?.build()
    }

    /// Create a text response
    pub fn text(text: impl Into<String>) -> Result<Response> {
        ResponseBuilder::new().text(text).build()
    }

    /// Create an HTML response
    pub fn html(html: impl Into<String>) -> Result<Response> {
        ResponseBuilder::new().html(html).build()
    }

    /// Create a redirect response
    pub fn redirect(location: &str, status: Option<u16>) -> Result<Response> {
        let status = status.unwrap_or(302);
        ResponseBuilder::new()
            .status(status)
            .header("Location", location)
            .build()
    }

    /// Create a not found response
    pub fn not_found() -> Result<Response> {
        ResponseBuilder::new()
            .status(404)
            .json(&serde_json::json!({
                "error": "NotFound",
                "message": "The requested resource was not found"
            }))?
            .build()
    }

    /// Create an error response from UltimoError
    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");
    }
}