Skip to main content

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