rama-http 0.3.0-rc1

rama http layers, services and other utilities
use std::{convert::Infallible, io};

use rama_core::bytes::Bytes;
use rama_core::telemetry::tracing;
use rama_core::{Service, error::BoxError};
use rama_http_headers::{AcceptRanges, ContentType, HeaderMapExt as _, HttpResponseBuilderExt};

use super::open_file::{FileOpened, OpenFileOutput};
use crate::headers::encoding::Encoding;
use crate::{
    Body, HeaderValue, Request, Response, StatusCode, StreamingBody,
    body::util::BodyExt,
    header::{self, ALLOW},
    service::fs::AsyncReadBody,
    service::web::response::IntoResponse,
};

#[cfg(feature = "html")]
use crate::service::web::response::Html;

/// Consume the result of opening a file and create an appropriate HTTP response.
/// Handles various file opening outcomes including success, redirection, HTML listing, and errors.
pub(super) async fn consume_open_file_result<ReqBody, ResBody, F>(
    open_file_result: Result<OpenFileOutput, std::io::Error>,
    fallback_and_request: Option<(&F, Request<ReqBody>)>,
) -> Result<Response, std::io::Error>
where
    F: Service<Request<ReqBody>, Output = Response<ResBody>, Error = Infallible> + Clone,
    ResBody: StreamingBody<Data = Bytes> + Send + Sync + 'static,
    ResBody::Error: Into<BoxError>,
{
    match open_file_result {
        Ok(OpenFileOutput::FileOpened(file_output)) => Ok(build_response(*file_output)),

        Ok(OpenFileOutput::Redirect { location }) => {
            let mut res = response_with_status(StatusCode::TEMPORARY_REDIRECT);
            res.headers_mut()
                .insert(rama_http_types::header::LOCATION, location);
            Ok(res)
        }

        #[cfg(feature = "html")]
        Ok(OpenFileOutput::Html(payload)) => Ok(Html(payload).into_response()),

        Ok(OpenFileOutput::FileNotFound | OpenFileOutput::InvalidFilename) => {
            if let Some((fallback, request)) = fallback_and_request {
                serve_fallback(fallback, request).await
            } else {
                Ok(not_found())
            }
        }

        Ok(OpenFileOutput::PreconditionFailed) => {
            Ok(response_with_status(StatusCode::PRECONDITION_FAILED))
        }

        // RFC 9110 §15.4.5: a 304 response must carry the validators (ETag / Last-Modified)
        // that would have been sent on a 200, so caches can refresh their stored metadata.
        Ok(OpenFileOutput::NotModified {
            etag,
            last_modified,
        }) => {
            let mut res = response_with_status(StatusCode::NOT_MODIFIED);
            if let Some(etag) = etag {
                res.headers_mut().typed_insert(etag);
            }
            if let Some(last_modified) = last_modified
                && let Ok(value) = HeaderValue::try_from(last_modified.0.to_string())
            {
                res.headers_mut().insert(header::LAST_MODIFIED, value);
            }
            Ok(res)
        }

        Err(err) => {
            #[cfg(target_family = "unix")]
            // 20 = libc::ENOTDIR => "not a directory
            // when `io_error_more` landed, this can be changed
            // to checking for `io::ErrorKind::NotADirectory`.
            // https://github.com/rust-lang/rust/issues/86442
            let error_is_not_a_directory = err.raw_os_error() == Some(20);
            #[cfg(not(target_family = "unix"))]
            let error_is_not_a_directory = false;

            if matches!(
                err.kind(),
                io::ErrorKind::NotFound | io::ErrorKind::PermissionDenied
            ) || error_is_not_a_directory
            {
                if let Some((fallback, request)) = fallback_and_request {
                    serve_fallback(fallback, request).await
                } else {
                    Ok(not_found())
                }
            } else {
                Err(err)
            }
        }
    }
}

#[inline(always)]
pub(super) fn method_not_allowed() -> Response {
    let mut res = response_with_status(StatusCode::METHOD_NOT_ALLOWED);
    res.headers_mut()
        .insert(ALLOW, HeaderValue::from_static("GET,HEAD"));
    res
}

#[inline(always)]
fn response_with_status(status: StatusCode) -> Response {
    status.into_response()
}

#[inline(always)]
pub(super) fn not_found() -> Response {
    response_with_status(StatusCode::NOT_FOUND)
}

/// Serve a request using the fallback service and convert the response body.
pub(super) async fn serve_fallback<F, B, FResBody>(
    fallback: &F,
    req: Request<B>,
) -> Result<Response, std::io::Error>
where
    F: Service<Request<B>, Output = Response<FResBody>, Error = Infallible>,
    FResBody: StreamingBody<Data = Bytes, Error: Into<BoxError>> + Send + Sync + 'static,
{
    let response = fallback.serve(req).await.unwrap();
    Ok(response
        .map(|body| {
            body.map_err(|err| match err.into().downcast::<io::Error>() {
                Ok(err) => *err,
                Err(err) => io::Error::other(err),
            })
            .boxed()
        })
        .map(Body::new))
}

/// Build an HTTP response from a successfully opened file.
/// Handles range requests, content encoding, and appropriate headers.
fn build_response(output: FileOpened) -> Response {
    let size = output.extent.file_size();

    let mut builder = Response::builder()
        .typed_header(ContentType::new(output.mime_value))
        .typed_header(AcceptRanges::bytes());

    if let Some(encoding) = output
        .maybe_encoding
        .filter(|encoding| *encoding != Encoding::Identity)
    {
        builder = builder.header(header::CONTENT_ENCODING, HeaderValue::from(encoding));
    }

    // Per RFC 9110 §12.5.3: when the response *could have* been selected by
    // `Accept-Encoding` — i.e. ServeDir was configured with precompressed
    // variants — caches need `Vary: Accept-Encoding` to avoid serving a
    // compressed response to a client that only asked for identity (or
    // vice-versa). This must be emitted even for uncompressed responses,
    // because the negotiation outcome is still request-dependent.
    if output.precompression_configured {
        builder = builder.header(header::VARY, HeaderValue::from_static("accept-encoding"));
    }

    if let Some(last_modified) = output.last_modified {
        builder = builder.header(header::LAST_MODIFIED, last_modified.0.to_string());
    }

    if let Some(etag) = output.etag {
        builder = builder.typed_header(etag);
    }

    match output.maybe_range {
        Some(Ok(ranges)) => {
            if let Some(range) = ranges.first() {
                if ranges.len() > 1 {
                    builder
                        .header(header::CONTENT_RANGE, format!("bytes */{size}"))
                        .status(StatusCode::RANGE_NOT_SATISFIABLE)
                        .body(Body::from(Bytes::from(
                            "Cannot serve multipart range requests",
                        )))
                        .unwrap_or_else(|err| {
                            tracing::debug!("failed to create RANGE_NOT_SATISFIABLE response: {err}; error 500 resp instead...");
                            StatusCode::INTERNAL_SERVER_ERROR.into_response()
                        })
                } else {
                    let range_size = range.end() - range.start() + 1;
                    let body = if let Some(reader) = output.extent.into_reader() {
                        Body::new(
                            AsyncReadBody::with_capacity_limited(
                                reader,
                                output.chunk_size,
                                range_size,
                            )
                            .boxed(),
                        )
                    } else {
                        Body::empty()
                    };

                    let content_length = if size == 0 {
                        0
                    } else {
                        range.end() - range.start() + 1
                    };

                    builder
                        .header(
                            header::CONTENT_RANGE,
                            format!("bytes {}-{}/{}", range.start(), range.end(), size),
                        )
                        .header(header::CONTENT_LENGTH, content_length)
                        .status(StatusCode::PARTIAL_CONTENT)
                        .body(body)
                        .unwrap_or_else(|err| {
                            tracing::debug!("failed to create PARTIAL_CONTENT response: {err}; error 500 resp instead...");
                            StatusCode::INTERNAL_SERVER_ERROR.into_response()
                        })
                }
            } else {
                builder
                    .header(header::CONTENT_RANGE, format!("bytes */{size}"))
                    .status(StatusCode::RANGE_NOT_SATISFIABLE)
                    .body(Body::from(Bytes::from(
                        "No range found after parsing range header, please file an issue",
                    )))
                    .unwrap_or_else(|err| {
                        tracing::debug!("failed to create RANGE_NOT_SATISFIABLE response: {err}; error 500 resp instead...");
                        StatusCode::INTERNAL_SERVER_ERROR.into_response()
                    })
            }
        }

        Some(Err(_)) => builder
            .header(header::CONTENT_RANGE, format!("bytes */{size}"))
            .status(StatusCode::RANGE_NOT_SATISFIABLE)
            .body(Body::empty())
            .unwrap_or_else(|err| {
                tracing::debug!("failed to create RANGE_NOT_SATISFIABLE response: {err}; error 500 resp instead...");
                StatusCode::INTERNAL_SERVER_ERROR.into_response()
            }),

        // Not a range request
        None => {
            let body = if let Some(reader) = output.extent.into_reader() {
                Body::new(AsyncReadBody::with_capacity(reader, output.chunk_size).boxed())
            } else {
                Body::empty()
            };

            builder
                .header(header::CONTENT_LENGTH, size)
                .body(body)
                .unwrap_or_else(|err| {
                    tracing::debug!("failed to create non-range content-length response: {err}; error 500 resp instead...");
                    StatusCode::INTERNAL_SERVER_ERROR.into_response()
                })
        }
    }
}