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}