arcly-http 0.5.0

Enterprise-grade NestJS-inspired web framework on axum: zero-lock DI, declarative controllers, multi-tenant data routing, transactional outbox, ABAC, and a self-documenting OpenAPI surface
Documentation
//! Zero-axum public HTTP types.
//!
//! User code says `arcly_http::Response`, `arcly_http::Json`, etc. — no
//! `axum::…` paths in the public surface. Internally these are thin
//! delegations to axum, so we keep its battle-tested implementation without
//! welding it into the public API.
//!
//! Why a private `axum::response::Response` re-export rather than a full
//! newtype: pinning `axum::response::Response` is the *one* type the
//! framework's boundary code constructs; making it a newtype would force
//! every adapter / interceptor to unwrap repeatedly with zero behavioural
//! benefit. A `pub use` rename achieves the same "no axum in user paths"
//! outcome at zero runtime cost.

use serde::Serialize;

/// The framework's response type. Same memory shape as axum's; the alias
/// keeps the user-visible path `arcly_http::Response`.
pub use axum::response::Response;

// ─── Raw-response construction kit ─────────────────────────────────────────
//
// Everything below lets user code hand-build a `Response` — custom status,
// headers, byte/streaming body — without ever naming `axum`. These are thin
// renames of the underlying `http`/`axum` types (identical memory shape, zero
// cost); the point is purely that no `axum::…` path appears in user code.

/// Response/request body. `Body::from(bytes)`, `Body::from("text")`, etc.
pub use axum::body::Body;
/// Raw bytes buffer (`bytes::Bytes`) — what [`RequestContext::body`] returns
/// and what byte-oriented responses carry.
///
/// [`RequestContext::body`]: crate::web::RequestContext::body
pub use bytes::Bytes;
/// Decomposed request head (method, uri, headers, …). Named in the
/// [`BoundaryFilter`] trait, so it must be reachable without `axum`.
///
/// [`BoundaryFilter`]: crate::web::boundary::BoundaryFilter
pub use http::request::Parts as RequestParts;
/// The builder returned by [`Response::builder`] — re-exported so it can be
/// named in user function signatures (e.g. a `with_cors(b: ResponseBuilder)`
/// helper) without reaching for `axum`.
pub use http::response::Builder as ResponseBuilder;
/// HTTP method (`Method::GET`, …).
pub use http::Method;
/// HTTP status code (`StatusCode::OK`, `StatusCode::PARTIAL_CONTENT`, …).
pub use http::StatusCode;
/// Request URI.
pub use http::Uri;
/// Owned, validated header name / value, and the multimap that holds them.
pub use http::{HeaderMap, HeaderName, HeaderValue};

/// Standard header-name constants: `header::CONTENT_TYPE`, `header::LOCATION`,
/// `header::CONTENT_RANGE`, `header::CACHE_CONTROL`, `header::ACCEPT_RANGES`,
/// the `header::ACCESS_CONTROL_*` family, and the rest of the IANA registry.
pub mod header {
    pub use http::header::*;
}

/// Build a `Response` from a status, content type, and raw bytes.
///
/// Covers the common "I have bytes and a content type" case (media segments,
/// generated files, opaque blobs) without hand-rolling a builder. Sets
/// `Content-Type` and `Content-Length`.
///
/// ```
/// use arcly_http::http::{bytes_response, Bytes, StatusCode};
/// let r = bytes_response(StatusCode::OK, "application/octet-stream", Bytes::from_static(b"hi"));
/// assert_eq!(r.status(), StatusCode::OK);
/// ```
pub fn bytes_response(status: StatusCode, content_type: &str, body: Bytes) -> Response {
    let len = body.len();
    Response::builder()
        .status(status)
        .header(header::CONTENT_TYPE, content_type)
        .header(header::CONTENT_LENGTH, len)
        .body(Body::from(body))
        .expect("valid bytes response")
}

/// Serve a (possibly partial) byte range from a fully-buffered resource.
///
/// Pass the parsed `range` as an inclusive `(start, end)` over `full`; returns
/// `206 Partial Content` with `Content-Range`/`Accept-Ranges` when a range is
/// given (and satisfiable), otherwise `200 OK` with the whole body. Designed
/// for media delivery (HLS/DASH segments, progressive download).
///
/// ```
/// use arcly_http::http::{byte_range, Bytes};
/// let full = Bytes::from_static(b"0123456789");
/// let partial = byte_range("video/mp4", &full, Some((2, 5)));
/// assert_eq!(partial.status().as_u16(), 206);
/// let whole = byte_range("video/mp4", &full, None);
/// assert_eq!(whole.status().as_u16(), 200);
/// ```
pub fn byte_range(content_type: &str, full: &Bytes, range: Option<(u64, u64)>) -> Response {
    let total = full.len() as u64;
    match range {
        Some((start, end)) if start <= end && start < total => {
            let end = end.min(total - 1);
            let slice = full.slice(start as usize..=end as usize); // inclusive
            Response::builder()
                .status(StatusCode::PARTIAL_CONTENT)
                .header(header::CONTENT_TYPE, content_type)
                .header(header::ACCEPT_RANGES, "bytes")
                .header(
                    header::CONTENT_RANGE,
                    format!("bytes {start}-{end}/{total}"),
                )
                .header(header::CONTENT_LENGTH, end - start + 1)
                .body(Body::from(slice))
                .expect("valid range response")
        }
        _ => bytes_response(StatusCode::OK, content_type, full.clone()),
    }
}

/// Convert a value into a `Response`.
///
/// `arcly_http::IntoResponse` is a thin re-trait over axum's so user code
/// never types `axum`. A blanket impl makes every axum-compatible type
/// automatically satisfy ours.
pub trait IntoResponse: Sized {
    fn into_response(self) -> Response;
}

impl<T: axum::response::IntoResponse> IntoResponse for T {
    #[inline]
    fn into_response(self) -> Response {
        axum::response::IntoResponse::into_response(self)
    }
}

/// JSON response wrapper. `Json(body)` writes `Content-Type: application/json`
/// and serialises `body` with `serde_json`. Same surface as axum's `Json` so
/// the route macro's return-type walker (`Json<T> | Result<Json<T>, _>`)
/// keeps working without changes.
pub struct Json<T>(pub T);

impl<T: Serialize> axum::response::IntoResponse for Json<T> {
    #[inline]
    fn into_response(self) -> axum::response::Response {
        axum::response::IntoResponse::into_response(axum::Json(self.0))
    }
}

impl<T> From<T> for Json<T> {
    #[inline]
    fn from(v: T) -> Self {
        Json(v)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn bytes_response_sets_type_and_length() {
        let r = bytes_response(StatusCode::OK, "text/plain", Bytes::from_static(b"hello"));
        assert_eq!(r.status(), StatusCode::OK);
        assert_eq!(r.headers().get(header::CONTENT_TYPE).unwrap(), "text/plain");
        assert_eq!(r.headers().get(header::CONTENT_LENGTH).unwrap(), "5");
    }

    #[test]
    fn byte_range_partial_content() {
        let full = Bytes::from_static(b"0123456789");
        let r = byte_range("application/octet-stream", &full, Some((2, 5)));
        assert_eq!(r.status(), StatusCode::PARTIAL_CONTENT);
        assert_eq!(
            r.headers().get(header::CONTENT_RANGE).unwrap(),
            "bytes 2-5/10"
        );
        assert_eq!(r.headers().get(header::CONTENT_LENGTH).unwrap(), "4");
        assert_eq!(r.headers().get(header::ACCEPT_RANGES).unwrap(), "bytes");
    }

    #[test]
    fn byte_range_clamps_end_past_eof() {
        let full = Bytes::from_static(b"0123456789");
        // end past EOF clamps to last byte (index 9).
        let r = byte_range("application/octet-stream", &full, Some((8, 100)));
        assert_eq!(r.status(), StatusCode::PARTIAL_CONTENT);
        assert_eq!(
            r.headers().get(header::CONTENT_RANGE).unwrap(),
            "bytes 8-9/10"
        );
        assert_eq!(r.headers().get(header::CONTENT_LENGTH).unwrap(), "2");
    }

    #[test]
    fn byte_range_no_range_is_full_200() {
        let full = Bytes::from_static(b"0123456789");
        let r = byte_range("application/octet-stream", &full, None);
        assert_eq!(r.status(), StatusCode::OK);
        assert_eq!(r.headers().get(header::CONTENT_LENGTH).unwrap(), "10");
    }

    #[test]
    fn byte_range_start_past_eof_falls_back_to_full() {
        let full = Bytes::from_static(b"0123456789");
        // Unsatisfiable start → whole body, 200 (callers may prefer 416; the
        // helper degrades safely rather than erroring).
        let r = byte_range("application/octet-stream", &full, Some((50, 60)));
        assert_eq!(r.status(), StatusCode::OK);
    }
}