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}