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