Skip to main content

arcly_http_core/
http.rs

1//! Zero-axum public HTTP types.
2//!
3//! User code says `arcly_http::Response`, `arcly_http::Json`, etc. — no
4//! `axum::…` paths in the public surface. Internally these are thin
5//! delegations to axum, so we keep its battle-tested implementation without
6//! welding it into the public API.
7//!
8//! Why a private `axum::response::Response` re-export rather than a full
9//! newtype: pinning `axum::response::Response` is the *one* type the
10//! framework's boundary code constructs; making it a newtype would force
11//! every adapter / interceptor to unwrap repeatedly with zero behavioural
12//! benefit. A `pub use` rename achieves the same "no axum in user paths"
13//! outcome at zero runtime cost.
14
15use serde::Serialize;
16
17/// The framework's response type. Same memory shape as axum's; the alias
18/// keeps the user-visible path `arcly_http::Response`.
19pub use axum::response::Response;
20
21// ─── Raw-response construction kit ─────────────────────────────────────────
22//
23// Everything below lets user code hand-build a `Response` — custom status,
24// headers, byte/streaming body — without ever naming `axum`. These are thin
25// renames of the underlying `http`/`axum` types (identical memory shape, zero
26// cost); the point is purely that no `axum::…` path appears in user code.
27
28/// Buffer a [`Body`] into [`Bytes`] up to a cap. Re-exported so subsystem
29/// modules can read response bodies (PII masking, etc.) without naming axum.
30pub use axum::body::to_bytes;
31/// Response/request body. `Body::from(bytes)`, `Body::from("text")`, etc.
32pub use axum::body::Body;
33/// Raw bytes buffer (`bytes::Bytes`) — what [`RequestContext::body`] returns
34/// and what byte-oriented responses carry.
35///
36/// [`RequestContext::body`]: crate::web::RequestContext::body
37pub use bytes::Bytes;
38/// Decomposed request head (method, uri, headers, …). Named in the
39/// [`BoundaryFilter`] trait, so it must be reachable without `axum`.
40///
41/// [`BoundaryFilter`]: crate::web::boundary::BoundaryFilter
42pub use http::request::Parts as RequestParts;
43/// The builder returned by [`Response::builder`] — re-exported so it can be
44/// named in user function signatures (e.g. a `with_cors(b: ResponseBuilder)`
45/// helper) without reaching for `axum`.
46pub use http::response::Builder as ResponseBuilder;
47/// HTTP method (`Method::GET`, …).
48pub use http::Method;
49/// HTTP status code (`StatusCode::OK`, `StatusCode::PARTIAL_CONTENT`, …).
50pub use http::StatusCode;
51/// Request URI.
52pub use http::Uri;
53/// Owned, validated header name / value, and the multimap that holds them.
54pub use http::{HeaderMap, HeaderName, HeaderValue};
55
56/// Standard header-name constants: `header::CONTENT_TYPE`, `header::LOCATION`,
57/// `header::CONTENT_RANGE`, `header::CACHE_CONTROL`, `header::ACCEPT_RANGES`,
58/// the `header::ACCESS_CONTROL_*` family, and the rest of the IANA registry.
59pub mod header {
60    pub use http::header::*;
61}
62
63/// Build a `Response` from a status, content type, and raw bytes.
64///
65/// Covers the common "I have bytes and a content type" case (media segments,
66/// generated files, opaque blobs) without hand-rolling a builder. Sets
67/// `Content-Type` and `Content-Length`.
68///
69/// ```
70/// use arcly_http_core::http::{bytes_response, Bytes, StatusCode};
71/// let r = bytes_response(StatusCode::OK, "application/octet-stream", Bytes::from_static(b"hi"));
72/// assert_eq!(r.status(), StatusCode::OK);
73/// ```
74pub fn bytes_response(status: StatusCode, content_type: &str, body: Bytes) -> Response {
75    let len = body.len();
76    Response::builder()
77        .status(status)
78        .header(header::CONTENT_TYPE, content_type)
79        .header(header::CONTENT_LENGTH, len)
80        .body(Body::from(body))
81        .expect("valid bytes response")
82}
83
84/// Serve a (possibly partial) byte range from a fully-buffered resource.
85///
86/// Pass the parsed `range` as an inclusive `(start, end)` over `full`; returns
87/// `206 Partial Content` with `Content-Range`/`Accept-Ranges` when a range is
88/// given (and satisfiable), otherwise `200 OK` with the whole body. Designed
89/// for media delivery (HLS/DASH segments, progressive download).
90///
91/// ```
92/// use arcly_http_core::http::{byte_range, Bytes};
93/// let full = Bytes::from_static(b"0123456789");
94/// let partial = byte_range("video/mp4", &full, Some((2, 5)));
95/// assert_eq!(partial.status().as_u16(), 206);
96/// let whole = byte_range("video/mp4", &full, None);
97/// assert_eq!(whole.status().as_u16(), 200);
98/// ```
99pub fn byte_range(content_type: &str, full: &Bytes, range: Option<(u64, u64)>) -> Response {
100    let total = full.len() as u64;
101    match range {
102        Some((start, end)) if start <= end && start < total => {
103            let end = end.min(total - 1);
104            let slice = full.slice(start as usize..=end as usize); // inclusive
105            Response::builder()
106                .status(StatusCode::PARTIAL_CONTENT)
107                .header(header::CONTENT_TYPE, content_type)
108                .header(header::ACCEPT_RANGES, "bytes")
109                .header(
110                    header::CONTENT_RANGE,
111                    format!("bytes {start}-{end}/{total}"),
112                )
113                .header(header::CONTENT_LENGTH, end - start + 1)
114                .body(Body::from(slice))
115                .expect("valid range response")
116        }
117        _ => bytes_response(StatusCode::OK, content_type, full.clone()),
118    }
119}
120
121/// Convert a value into a `Response`.
122///
123/// `arcly_http::IntoResponse` is a thin re-trait over axum's so user code
124/// never types `axum`. A blanket impl makes every axum-compatible type
125/// automatically satisfy ours.
126pub trait IntoResponse: Sized {
127    fn into_response(self) -> Response;
128}
129
130impl<T: axum::response::IntoResponse> IntoResponse for T {
131    #[inline]
132    fn into_response(self) -> Response {
133        axum::response::IntoResponse::into_response(self)
134    }
135}
136
137/// JSON response wrapper. `Json(body)` writes `Content-Type: application/json`
138/// and serialises `body` with `serde_json`. Same surface as axum's `Json` so
139/// the route macro's return-type walker (`Json<T> | Result<Json<T>, _>`)
140/// keeps working without changes.
141pub struct Json<T>(pub T);
142
143impl<T: Serialize> axum::response::IntoResponse for Json<T> {
144    #[inline]
145    fn into_response(self) -> axum::response::Response {
146        axum::response::IntoResponse::into_response(axum::Json(self.0))
147    }
148}
149
150impl<T> From<T> for Json<T> {
151    #[inline]
152    fn from(v: T) -> Self {
153        Json(v)
154    }
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160
161    #[test]
162    fn bytes_response_sets_type_and_length() {
163        let r = bytes_response(StatusCode::OK, "text/plain", Bytes::from_static(b"hello"));
164        assert_eq!(r.status(), StatusCode::OK);
165        assert_eq!(r.headers().get(header::CONTENT_TYPE).unwrap(), "text/plain");
166        assert_eq!(r.headers().get(header::CONTENT_LENGTH).unwrap(), "5");
167    }
168
169    #[test]
170    fn byte_range_partial_content() {
171        let full = Bytes::from_static(b"0123456789");
172        let r = byte_range("application/octet-stream", &full, Some((2, 5)));
173        assert_eq!(r.status(), StatusCode::PARTIAL_CONTENT);
174        assert_eq!(
175            r.headers().get(header::CONTENT_RANGE).unwrap(),
176            "bytes 2-5/10"
177        );
178        assert_eq!(r.headers().get(header::CONTENT_LENGTH).unwrap(), "4");
179        assert_eq!(r.headers().get(header::ACCEPT_RANGES).unwrap(), "bytes");
180    }
181
182    #[test]
183    fn byte_range_clamps_end_past_eof() {
184        let full = Bytes::from_static(b"0123456789");
185        // end past EOF clamps to last byte (index 9).
186        let r = byte_range("application/octet-stream", &full, Some((8, 100)));
187        assert_eq!(r.status(), StatusCode::PARTIAL_CONTENT);
188        assert_eq!(
189            r.headers().get(header::CONTENT_RANGE).unwrap(),
190            "bytes 8-9/10"
191        );
192        assert_eq!(r.headers().get(header::CONTENT_LENGTH).unwrap(), "2");
193    }
194
195    #[test]
196    fn byte_range_no_range_is_full_200() {
197        let full = Bytes::from_static(b"0123456789");
198        let r = byte_range("application/octet-stream", &full, None);
199        assert_eq!(r.status(), StatusCode::OK);
200        assert_eq!(r.headers().get(header::CONTENT_LENGTH).unwrap(), "10");
201    }
202
203    #[test]
204    fn byte_range_start_past_eof_falls_back_to_full() {
205        let full = Bytes::from_static(b"0123456789");
206        // Unsatisfiable start → whole body, 200 (callers may prefer 416; the
207        // helper degrades safely rather than erroring).
208        let r = byte_range("application/octet-stream", &full, Some((50, 60)));
209        assert_eq!(r.status(), StatusCode::OK);
210    }
211}