Skip to main content

webx_core/
http.rs

1//! HTTP abstraction traits: IHttpContext, IHttpRequest, IHttpResponse.
2//!
3//! This module defines the core interfaces for HTTP request/response handling
4//! in the rust-webx framework. All types are defined as traits to enable testability
5//! and middleware composition.
6//!
7//! # Traits
8//!
9//! - [`IHttpContext`] — The central context for a single HTTP request, providing
10//!   access to request, response, and authentication claims.
11//! - [`IHttpRequest`] — Read-only access to the incoming HTTP request.
12//! - [`IHttpResponse`] — Mutable access to the outgoing HTTP response.
13//! - [`IClaimsExt`] — Extension for storing/retrieving authentication claims.
14//! - [`FromHttpContext`] — Build a request struct from HTTP request data.
15//!
16//! # Helpers
17//!
18//! - [`read_json_body`] — Deserialize JSON from the request body.
19//! - [`write_json_response`] — Serialize a value as JSON into the response.
20
21use crate::auth::IClaims;
22use crate::error::Result;
23use std::collections::HashMap;
24use std::path::PathBuf;
25use std::sync::Arc;
26use std::time::SystemTime;
27use tokio::io::{AsyncRead, AsyncSeek};
28
29/// Common HTTP status codes.
30pub struct HttpStatus;
31
32impl HttpStatus {
33    pub const OK: u16 = 200;
34    pub const CREATED: u16 = 201;
35    pub const NO_CONTENT: u16 = 204;
36    pub const PARTIAL_CONTENT: u16 = 206;
37    pub const NOT_MODIFIED: u16 = 304;
38    pub const BAD_REQUEST: u16 = 400;
39    pub const UNAUTHORIZED: u16 = 401;
40    pub const FORBIDDEN: u16 = 403;
41    pub const NOT_FOUND: u16 = 404;
42    pub const RANGE_NOT_SATISFIABLE: u16 = 416;
43    pub const PAYLOAD_TOO_LARGE: u16 = 413;
44    pub const UNSUPPORTED_MEDIA_TYPE: u16 = 415;
45    pub const INTERNAL_SERVER_ERROR: u16 = 500;
46}
47
48/// An inclusive byte range, as requested by a `Range` header.
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub struct ByteRange {
51    /// First byte to send, inclusive.
52    pub start: u64,
53    /// Last byte to send, inclusive.
54    pub end: u64,
55}
56
57impl ByteRange {
58    /// Number of bytes covered by the range.
59    pub fn len(&self) -> u64 {
60        self.end.saturating_sub(self.start) + 1
61    }
62
63    /// A range is never empty: `start > end` is normalised away when parsing.
64    pub fn is_empty(&self) -> bool {
65        false
66    }
67}
68
69/// How a file response should be presented.
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
71pub enum Disposition {
72    /// Render in place — the default, used for images, PDFs and media.
73    #[default]
74    Inline,
75    /// Prompt the browser to save the file under [`FileBody::file_name`].
76    Attachment,
77}
78
79/// Read/write buffer size used for streamed response bodies and spooled uploads.
80///
81/// A 4 KiB buffer turns a 1 GiB transfer into ~262k reads *and* ~262k socket
82/// writes; 64 KiB cuts both by 16x while staying well inside cache. This is the
83/// size nginx, hyper and tower-http settle on for bulk transfer.
84pub const STREAM_BUF_SIZE: usize = 64 * 1024;
85
86/// An async reader that can also seek.
87///
88/// Seeking is what lets a stream answer `Range` requests, so a
89/// [`FileSource::SeekableStream`] needs both capabilities. Implemented
90/// automatically for anything that is `AsyncRead + AsyncSeek + Send + Unpin`,
91/// so you never implement it yourself.
92pub trait SeekableReader: AsyncRead + AsyncSeek + Send + Unpin {}
93
94impl<T: AsyncRead + AsyncSeek + Send + Unpin> SeekableReader for T {}
95
96/// Where a file response's bytes come from.
97///
98/// This mirrors the sources ASP.NET Core accepts for `File(...)`:
99/// a physical path, a stream, or an in-memory buffer. Pick the one you already
100/// have — none of them require materialising the content first.
101pub enum FileSource {
102    /// A file on disk.
103    ///
104    /// Streamed in 64 KiB chunks; the host opens it once and reads length and
105    /// validators from the same handle.
106    Path(PathBuf),
107    /// An async reader whose length is known and which can seek, so `Range`
108    /// requests can be served.
109    ///
110    /// Use this for object-storage handles, database LOB readers, archive
111    /// entries — anything that can jump to a byte offset.
112    SeekableStream {
113        /// The reader. Consumed when the response is sent.
114        reader: Box<dyn SeekableReader>,
115        /// Total length in bytes, so `Content-Length` and `Content-Range` are exact.
116        len: u64,
117    },
118    /// An async reader of unknown length.
119    ///
120    /// Sent with chunked transfer encoding and no `Content-Length`; `Range`
121    /// requests receive the full body. Use this for pipes, live producers and
122    /// compressed/encrypted streams where the final size is not known up front.
123    Stream(Box<dyn AsyncRead + Send + Unpin>),
124}
125
126impl std::fmt::Debug for FileSource {
127    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
128        match self {
129            FileSource::Path(path) => f.debug_tuple("Path").field(path).finish(),
130            FileSource::SeekableStream { len, .. } => f
131                .debug_struct("SeekableStream")
132                .field("len", len)
133                .finish_non_exhaustive(),
134            FileSource::Stream(_) => f.debug_tuple("Stream").finish(),
135        }
136    }
137}
138
139impl std::fmt::Debug for FileBody {
140    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
141        f.debug_struct("FileBody")
142            .field("source", &self.source)
143            .field("content_type", &self.content_type)
144            .field("file_name", &self.file_name)
145            .field("disposition", &self.disposition)
146            .field("enable_range_processing", &self.enable_range_processing)
147            .field("last_modified", &self.last_modified)
148            .field("entity_tag", &self.entity_tag)
149            .finish()
150    }
151}
152
153/// A file response — the rust-webx counterpart of ASP.NET Core's
154/// `File(...)` / `PhysicalFile(...)` / `Results.File(...)`.
155///
156/// The host streams the content and fills in the HTTP metadata:
157///
158/// * `Content-Length` — exact when it is known (path, seekable stream)
159/// * `ETag` / `Last-Modified` — derived from the file, or supplied via
160///   [`FileBody::entity_tag`] / [`FileBody::last_modified`] for streams
161/// * `Accept-Ranges` and `Range` handling (`206` / `416`) — when the source can
162///   seek and [`FileBody::enable_range_processing`] is left on
163/// * `If-Range`, `If-None-Match`, `If-Modified-Since` (`304`)
164///
165/// Build one with [`FileBody::path`], [`FileBody::stream`] or
166/// [`FileBody::seekable_stream`], then hand it to
167/// [`crate::route::scan::ResponseData::with_file`].
168pub struct FileBody {
169    /// Where the bytes come from.
170    pub source: FileSource,
171    /// Media type. When `None` the host infers it from the path extension, or
172    /// falls back to `application/octet-stream` for streams.
173    pub content_type: Option<String>,
174    /// Name used in `Content-Disposition`.
175    pub file_name: Option<String>,
176    /// Whether the content should be shown inline or downloaded.
177    pub disposition: Disposition,
178    /// Whether `Range` requests should be served (default `true`).
179    ///
180    /// Turn it off to force a single full response — useful when range
181    /// requests would multiply backend reads.
182    pub enable_range_processing: bool,
183    /// `Last-Modified` to advertise.
184    ///
185    /// Derived from the file for [`FileSource::Path`]; required for a stream if
186    /// you want `If-Modified-Since` to work.
187    pub last_modified: Option<SystemTime>,
188    /// `ETag` to advertise.
189    ///
190    /// Derived from size and mtime for [`FileSource::Path`]; required for a
191    /// stream if you want `If-None-Match` to work.
192    pub entity_tag: Option<String>,
193}
194
195impl FileBody {
196    /// Send the file at `path` — ASP.NET Core's `PhysicalFile(...)`.
197    pub fn path(path: impl Into<PathBuf>) -> Self {
198        Self {
199            source: FileSource::Path(path.into()),
200            content_type: None,
201            file_name: None,
202            disposition: Disposition::Inline,
203            enable_range_processing: true,
204            last_modified: None,
205            entity_tag: None,
206        }
207    }
208
209    /// Send an async reader of unknown length — ASP.NET Core's `File(Stream, ...)`.
210    ///
211    /// The response uses chunked transfer encoding and does not serve `Range`.
212    /// If you know the length and can seek, use [`FileBody::seekable_stream`] so
213    /// clients can resume and cache.
214    pub fn stream(reader: impl AsyncRead + Send + Unpin + 'static) -> Self {
215        Self {
216            source: FileSource::Stream(Box::new(reader)),
217            content_type: None,
218            file_name: None,
219            disposition: Disposition::Inline,
220            enable_range_processing: false,
221            last_modified: None,
222            entity_tag: None,
223        }
224    }
225
226    /// Send an async reader of known length that supports seeking.
227    ///
228    /// This is the stream form that keeps every HTTP nicety: exact
229    /// `Content-Length`, `Range`/`206`, and — with [`FileBody::entity_tag`] —
230    /// conditional requests.
231    pub fn seekable_stream(
232        reader: impl AsyncRead + AsyncSeek + Send + Unpin + 'static,
233        len: u64,
234    ) -> Self {
235        Self {
236            source: FileSource::SeekableStream {
237                reader: Box::new(reader),
238                len,
239            },
240            content_type: None,
241            file_name: None,
242            disposition: Disposition::Inline,
243            enable_range_processing: true,
244            last_modified: None,
245            entity_tag: None,
246        }
247    }
248
249    /// Send bytes compiled into the executable — ASP.NET Core's embedded
250    /// `ManifestEmbeddedFileProvider`.
251    ///
252    /// The slice is borrowed, never copied, and is served as a seekable source,
253    /// so `Content-Length`, `Range`/`206` and conditional requests all work.
254    /// Build one with `include_bytes!`-backed tables, such as the ones produced
255    /// by `webx::builder().web_root(...).build()` and included with
256    /// `webx::spa::embed_assets!()`.
257    ///
258    /// The extension-derived media type is not available for in-memory bytes,
259    /// so set [`FileBody::content_type`] explicitly. There is no mtime either,
260    /// so supply [`FileBody::entity_tag`] (a content hash is the usual choice)
261    /// if clients should be able to revalidate.
262    pub fn static_bytes(bytes: &'static [u8]) -> Self {
263        Self::seekable_stream(std::io::Cursor::new(bytes), bytes.len() as u64)
264    }
265
266    /// Override the media type instead of deriving it from the extension.
267    pub fn content_type(mut self, content_type: impl Into<String>) -> Self {
268        self.content_type = Some(content_type.into());
269        self
270    }
271
272    /// Send the file as a download named `file_name`.
273    pub fn download_name(mut self, file_name: impl Into<String>) -> Self {
274        self.file_name = Some(file_name.into());
275        self.disposition = Disposition::Attachment;
276        self
277    }
278
279    /// Send the file inline under `file_name`.
280    pub fn inline_name(mut self, file_name: impl Into<String>) -> Self {
281        self.file_name = Some(file_name.into());
282        self.disposition = Disposition::Inline;
283        self
284    }
285
286    /// Enable or disable `Range` processing.
287    pub fn enable_range_processing(mut self, enabled: bool) -> Self {
288        self.enable_range_processing = enabled;
289        self
290    }
291
292    /// Advertise an explicit `Last-Modified`.
293    pub fn last_modified(mut self, last_modified: SystemTime) -> Self {
294        self.last_modified = Some(last_modified);
295        self
296    }
297
298    /// Advertise an explicit `ETag`.
299    ///
300    /// Include the quotes: `"abc123"`. A strong tag lets clients resume with
301    /// `If-Range`.
302    pub fn entity_tag(mut self, entity_tag: impl Into<String>) -> Self {
303        self.entity_tag = Some(entity_tag.into());
304        self
305    }
306
307    /// Whether this source can seek, and therefore serve byte ranges.
308    pub fn is_range_capable(&self) -> bool {
309        self.enable_range_processing
310            && matches!(
311                self.source,
312                FileSource::Path(_) | FileSource::SeekableStream { .. }
313            )
314    }
315
316    /// The length in bytes, when it is known without touching the filesystem.
317    pub fn known_len(&self) -> Option<u64> {
318        match &self.source {
319            FileSource::Path(_) | FileSource::Stream(_) => None,
320            FileSource::SeekableStream { len, .. } => Some(*len),
321        }
322    }
323}
324
325/// The body of an HTTP response.
326pub enum ResponseBody {
327    /// Fully materialised bytes.
328    Bytes(Vec<u8>),
329    /// A file streamed by the host.
330    File(FileBody),
331}
332
333impl std::fmt::Debug for ResponseBody {
334    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
335        match self {
336            ResponseBody::Bytes(bytes) => f.debug_tuple("Bytes").field(&bytes.len()).finish(),
337            ResponseBody::File(file) => f.debug_tuple("File").field(file).finish(),
338        }
339    }
340}
341
342impl ResponseBody {
343    /// Length in bytes, when it is known without touching the filesystem.
344    pub fn len(&self) -> Option<u64> {
345        match self {
346            ResponseBody::Bytes(bytes) => Some(bytes.len() as u64),
347            ResponseBody::File(file) => file.known_len(),
348        }
349    }
350
351    /// Whether this body is a known-empty byte buffer.
352    pub fn is_empty(&self) -> bool {
353        matches!(self, ResponseBody::Bytes(bytes) if bytes.is_empty())
354    }
355
356    /// The in-memory bytes, when this is a byte body.
357    pub fn as_bytes(&self) -> Option<&[u8]> {
358        match self {
359            ResponseBody::Bytes(bytes) => Some(bytes),
360            ResponseBody::File(_) => None,
361        }
362    }
363
364    /// The file descriptor, when this is a file body.
365    pub fn as_file(&self) -> Option<&FileBody> {
366        match self {
367            ResponseBody::File(file) => Some(file),
368            ResponseBody::Bytes(_) => None,
369        }
370    }
371}
372
373impl From<Vec<u8>> for ResponseBody {
374    fn from(bytes: Vec<u8>) -> Self {
375        ResponseBody::Bytes(bytes)
376    }
377}
378
379impl From<String> for ResponseBody {
380    fn from(text: String) -> Self {
381        ResponseBody::Bytes(text.into_bytes())
382    }
383}
384
385impl From<&str> for ResponseBody {
386    fn from(text: &str) -> Self {
387        ResponseBody::Bytes(text.as_bytes().to_vec())
388    }
389}
390
391impl From<FileBody> for ResponseBody {
392    fn from(file: FileBody) -> Self {
393        ResponseBody::File(file)
394    }
395}
396
397// ---------------------------------------------------------------------------
398// Claims extension for IHttpContext — MUST be defined before IHttpContext
399// ---------------------------------------------------------------------------
400
401/// Extension trait that adds claims storage to an `IHttpContext`.
402///
403/// Implemented by `HttpContext` in `rust-webx-host`. This trait is a supertrait
404/// of `IHttpContext` so that authentication/authorization middleware can
405/// store and retrieve claims through `&mut dyn IHttpContext` directly.
406pub trait IClaimsExt {
407    /// Store authentication claims in the context.
408    fn set_claims(&mut self, claims: Box<dyn IClaims>);
409
410    /// Retrieve authentication claims from the context, if present.
411    fn claims(&self) -> Option<&dyn IClaims>;
412}
413
414/// HTTP context encapsulating the request, response, and service provider
415/// for the duration of a single HTTP request.
416///
417/// Extends `IClaimsExt` so middleware can store/retrieve auth claims.
418///
419/// Analogous to ASP.NET Core's HttpContext.
420pub trait IHttpContext: IClaimsExt + Send {
421    fn request(&self) -> &dyn IHttpRequest;
422    fn request_mut(&mut self) -> &mut dyn IHttpRequest;
423    fn response(&self) -> &dyn IHttpResponse;
424    fn response_mut(&mut self) -> &mut dyn IHttpResponse;
425}
426
427/// HTTP request abstraction.
428///
429/// Analogous to ASP.NET Core's HttpRequest.
430///
431/// Methods are non-generic to maintain dyn-compatibility.
432/// Use `serde_json::from_slice` on the raw body bytes for JSON deserialization.
433#[async_trait::async_trait]
434pub trait IHttpRequest: Send {
435    fn method(&self) -> &str;
436    fn path(&self) -> &str;
437    fn header(&self, name: &str) -> Option<&str>;
438    fn query(&self) -> &HashMap<String, String>;
439    fn route_params(&self) -> &HashMap<String, String>;
440    fn route_params_mut(&mut self) -> &mut HashMap<String, String>;
441
442    /// The original route pattern that was matched (e.g., `"/api/users/{id}"`).
443    /// Set by the router after successful route matching.
444    fn route_pattern(&self) -> Option<&str>;
445    fn route_pattern_mut(&mut self) -> &mut Option<String>;
446
447    /// Return the raw request body bytes.
448    ///
449    /// The body can only be read once; a second call reports an error rather
450    /// than silently returning an empty body.
451    async fn body_bytes(&mut self) -> Result<Vec<u8>>;
452
453    /// Return the request body as a UTF-8 string.
454    async fn body_text(&mut self) -> Result<String> {
455        let bytes = self.body_bytes().await?;
456        String::from_utf8(bytes).map_err(|e| crate::error::Error::Http(e.to_string()))
457    }
458
459    /// The request's media type, without parameters (e.g. `"multipart/form-data"`).
460    ///
461    /// Comparison is case-insensitive; see [`IHttpRequest::is_multipart`].
462    fn content_type(&self) -> Option<&str> {
463        self.header("content-type")
464            .map(|value| value.split(';').next().unwrap_or("").trim())
465            .filter(|value| !value.is_empty())
466    }
467
468    /// Whether the request declares `Content-Type: multipart/form-data`.
469    fn is_multipart(&self) -> bool {
470        self.content_type()
471            .is_some_and(|ct| ct.eq_ignore_ascii_case("multipart/form-data"))
472    }
473
474    /// Parse the request body as `multipart/form-data`.
475    ///
476    /// The parsed form is cached on the request, so repeated calls are cheap and
477    /// spooled uploads stay alive until the request is finished. Call
478    /// [`crate::form::FormFile::save_as`] from your handler to keep a file.
479    ///
480    /// The default implementation reports the request as unsupported; the
481    /// rust-webx HTTP host provides the real parser.
482    async fn multipart(&mut self) -> Result<Arc<crate::form::MultipartForm>> {
483        Err(crate::error::Error::UnsupportedMediaType(
484            "multipart/form-data is not supported by this request implementation".to_string(),
485        ))
486    }
487}
488
489/// HTTP response abstraction.
490///
491/// Analogous to ASP.NET Core's HttpResponse.
492///
493/// Methods are non-generic to maintain dyn-compatibility.
494#[async_trait::async_trait]
495pub trait IHttpResponse: Send {
496    /// Get the current HTTP status code.
497    fn status(&self) -> u16;
498    fn set_status(&mut self, code: u16);
499    fn set_header(&mut self, key: &str, value: &str);
500
501    /// Remove a response header by name. Default is a no-op.
502    fn remove_header(&mut self, _key: &str) {}
503
504    /// Whether a response body has been written.
505    fn has_body(&self) -> bool {
506        false
507    }
508
509    /// Write raw bytes as the response body.
510    async fn write_bytes(&mut self, data: Vec<u8>) -> Result<()>;
511
512    /// Replace the response body with a structured [`ResponseBody`].
513    ///
514    /// The default implementation handles in-memory bodies and rejects file
515    /// bodies; the rust-webx HTTP host overrides it to stream files with
516    /// `Range`, `ETag` and `Content-Disposition` support.
517    async fn write_body(&mut self, body: ResponseBody) -> Result<()> {
518        match body {
519            ResponseBody::Bytes(bytes) => self.write_bytes(bytes).await,
520            ResponseBody::File(_) => Err(crate::error::Error::Internal(
521                "file responses require the rust-webx HTTP host".to_string(),
522            )),
523        }
524    }
525
526    /// Write a UTF-8 string as the response body.
527    async fn write_text(&mut self, text: &str) -> Result<()> {
528        self.write_bytes(text.as_bytes().to_vec()).await
529    }
530
531    /// Read the current response body bytes.
532    ///
533    /// Returns an empty Vec if no body has been written.
534    /// Used by post-processing middleware (e.g. compression) in `after` hooks.
535    fn body_bytes(&self) -> Vec<u8> {
536        Vec::new()
537    }
538
539    /// Read a response header value by name.
540    ///
541    /// Returns None if the header is not set.
542    /// Used by post-processing middleware (e.g. compression) to inspect content-type.
543    fn header(&self, _key: &str) -> Option<&str> {
544        None
545    }
546}
547
548/// Build a `Content-Disposition` header value (RFC 6266).
549///
550/// A plain ASCII `filename` is always included for legacy clients, and a
551/// `filename*` (RFC 5987) parameter carries the real name so non-ASCII
552/// filenames survive the round trip.
553pub fn content_disposition_value(disposition: Disposition, file_name: &str) -> String {
554    let kind = match disposition {
555        Disposition::Inline => "inline",
556        Disposition::Attachment => "attachment",
557    };
558
559    let ascii: String = file_name
560        .chars()
561        .map(|ch| {
562            if ch.is_ascii() && ch != '"' && ch != '\\' && !ch.is_control() {
563                ch
564            } else {
565                '_'
566            }
567        })
568        .collect();
569
570    format!(
571        "{kind}; filename=\"{ascii}\"; filename*=UTF-8''{}",
572        percent_encode_ext_value(file_name)
573    )
574}
575
576/// Percent-encode using the `attr-char` set from RFC 5987.
577fn percent_encode_ext_value(value: &str) -> String {
578    let mut out = String::with_capacity(value.len());
579    for byte in value.as_bytes() {
580        let ch = *byte as char;
581        let is_attr_char = ch.is_ascii_alphanumeric()
582            || matches!(
583                ch,
584                '!' | '#' | '$' | '&' | '+' | '-' | '.' | '^' | '_' | '`' | '|' | '~'
585            );
586        if is_attr_char {
587            out.push(ch);
588        } else {
589            out.push_str(&format!("%{byte:02X}"));
590        }
591    }
592    out
593}
594
595/// Helper: build a JSON response by serializing a value and writing it.
596pub async fn write_json_response<T: serde::Serialize + Send>(
597    resp: &mut dyn IHttpResponse,
598    value: &T,
599) -> Result<()> {
600    let json = serde_json::to_vec(value)?;
601    resp.set_header("content-type", "application/json");
602    resp.write_bytes(json).await
603}
604
605/// Helper: read JSON from the request body.
606pub async fn read_json_body<T: serde::de::DeserializeOwned>(
607    req: &mut dyn IHttpRequest,
608) -> Result<T> {
609    let bytes = req.body_bytes().await?;
610    serde_json::from_slice(&bytes).map_err(crate::error::Error::Serialization)
611}
612
613/// Type alias for JSON responses in controller methods.
614pub type Json<T> = T;
615
616/// Trait for constructing a request struct from the HTTP context.
617///
618/// Implemented automatically by the `#[get]`, `#[post]`, `#[put]`, `#[delete]`
619/// proc macros for request types. Custom implementations are also supported.
620///
621/// # Automatic implementation
622///
623/// For POST/PUT/PATCH routes, the macro will attempt `serde_json::from_slice`
624/// on the request body. For GET/DELETE routes with path parameters, the macro
625/// will extract parameters from `ctx.request().route_params()` by field name.
626///
627/// # Manual implementation
628///
629/// ```ignore
630/// #[async_trait::async_trait]
631/// impl FromHttpContext for MyRequest {
632///     async fn from_http_context(ctx: &dyn IHttpContext) -> Result<Self> {
633///         // custom construction logic
634///     }
635/// }
636/// ```
637#[async_trait::async_trait]
638pub trait FromHttpContext: Sized + Send + 'static {
639    async fn from_http_context(ctx: &dyn IHttpContext) -> Result<Self>;
640}