Skip to main content

actus_reply/
reply.rs

1//! Contains the user-facing API for creating replies.
2use bytes::Bytes;
3use futures_util::{Stream, StreamExt};
4use serde::Serialize;
5use serde_json::Value;
6use std::any::Any;
7use std::borrow::Cow;
8use std::collections::HashMap;
9use std::fmt;
10use std::io;
11use std::pin::Pin;
12use std::time::Duration;
13
14// ===================== Core Types =====================
15
16/// A boxed, `Send + Sync` stream of body chunks, used by [`ReplyData::Stream`]
17/// and SSE responses.
18pub type BodyStream = Pin<Box<dyn Stream<Item = Result<Bytes, io::Error>> + Send + Sync>>;
19
20/// The concrete payload of a successful reply. The [`Finalizer`](crate::Finalizer)
21/// turns it into an HTTP response. Build one with the [`reply!`](crate::reply!)
22/// macro or the `reply::*` constructor functions rather than by hand.
23#[derive()]
24pub enum ReplyData {
25    /// A JSON body (`Content-Type: application/json`).
26    Json(Value),
27    /// Raw bytes plus the `Content-Type` to send. There is no default —
28    /// callers pick the type at the call site (`application/zip`,
29    /// `image/png`, `application/octet-stream`, …). Construct via
30    /// [`bytes()`](crate::bytes). The finalizer emits the header verbatim.
31    Bytes {
32        /// The `Content-Type` header value to send with the bytes.
33        content_type: Cow<'static, str>,
34        /// The raw response body.
35        data: Vec<u8>,
36    },
37    /// An empty body — `204 No Content` by default.
38    Empty,
39    /// A reply carrying explicit status / headers (a [`ReplySpec`]) on top of
40    /// one of the other payload kinds.
41    Rich(Box<ReplySpec>),
42    /// A streaming body, written out as the stream yields.
43    Stream(BodyStream),
44    /// An HTTP connection-upgrade reply (e.g. WebSocket). Produced by
45    /// `actus::ws::upgrade(...)` (with the `websocket` feature) and consumed
46    /// by the server, which completes the handshake (`101 Switching Protocols`)
47    /// and then hands the upgraded connection to the handler. The boxed value
48    /// is opaque server plumbing — don't construct or inspect it directly.
49    Upgrade(Box<dyn Any + Send>),
50}
51
52// Manual `Debug` implementation to handle the non-Debug variants.
53impl fmt::Debug for ReplyData {
54    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
55        match self {
56            ReplyData::Json(j) => f.debug_tuple("Json").field(j).finish(),
57            ReplyData::Bytes { content_type, data } => f
58                .debug_struct("Bytes")
59                .field("content_type", content_type)
60                .field("data", data)
61                .finish(),
62            ReplyData::Empty => write!(f, "Empty"),
63            ReplyData::Rich(r) => f.debug_tuple("Rich").field(r).finish(),
64            ReplyData::Stream(_) => f.debug_tuple("Stream").field(&"...").finish(),
65            ReplyData::Upgrade(_) => f.debug_tuple("Upgrade").field(&"...").finish(),
66        }
67    }
68}
69
70// Manual `PartialEq` implementation. Streams are never equal.
71impl PartialEq for ReplyData {
72    fn eq(&self, other: &Self) -> bool {
73        match (self, other) {
74            (ReplyData::Json(l), ReplyData::Json(r)) => l == r,
75            (
76                ReplyData::Bytes {
77                    content_type: lc,
78                    data: ld,
79                },
80                ReplyData::Bytes {
81                    content_type: rc,
82                    data: rd,
83                },
84            ) => lc == rc && ld == rd,
85            (ReplyData::Empty, ReplyData::Empty) => true,
86            (ReplyData::Rich(l), ReplyData::Rich(r)) => l == r,
87            _ => false, // Streams and different variants are not equal
88        }
89    }
90}
91
92/// A handler's return type: `Ok(`[`ReplyData`]`)` for a success payload, or
93/// `Err(`[`WebError`]`)` for an error the framework renders as an HTTP error
94/// response. The [`reply!`](crate::reply!) macro produces the `Ok` side.
95pub type Reply = Result<ReplyData, WebError>;
96
97impl ReplyData {
98    /// Add a response header, replacing any header already on the reply
99    /// whose name matches **case-insensitively** — HTTP field names are
100    /// case-insensitive (RFC 9110 §5.1), so `add_header("cache-control", …)`
101    /// replaces a handler's `"Cache-Control"` rather than sitting beside it.
102    /// Lifts `self` into [`ReplyData::Rich`] if the current variant doesn't
103    /// already carry headers (Json/Bytes/Empty/Stream), so middleware `after`
104    /// hooks (and any code that has a `ReplyData` it wants to decorate) can
105    /// stamp headers without manually wrangling [`ReplySpec`]. The original
106    /// payload is preserved.
107    ///
108    /// To set a *default* — a header the handler may already have chosen —
109    /// check [`header`](Self::header) first, or this overwrites its choice.
110    ///
111    /// No-op on [`ReplyData::Upgrade`] — that variant is intercepted by the
112    /// server before the response body is finalized, and lifting it into
113    /// `Rich` would hide it from that interception.
114    pub fn add_header(&mut self, name: impl Into<String>, value: impl Into<String>) {
115        if matches!(self, ReplyData::Upgrade(_)) {
116            return;
117        }
118        let name = name.into();
119        let value = value.into();
120        if let ReplyData::Rich(spec) = self {
121            insert_header(&mut spec.headers, name, value);
122            return;
123        }
124        let payload = std::mem::replace(self, ReplyData::Empty);
125        let mut headers = HashMap::new();
126        headers.insert(name, value);
127        *self = ReplyData::Rich(Box::new(ReplySpec {
128            payload,
129            status: None,
130            headers,
131        }));
132    }
133
134    /// Set the response status, lifting into [`ReplyData::Rich`] if needed
135    /// (see [`add_header`](Self::add_header)).
136    pub fn set_status(&mut self, status: http::StatusCode) {
137        if matches!(self, ReplyData::Upgrade(_)) {
138            return;
139        }
140        if let ReplyData::Rich(spec) = self {
141            spec.status = Some(status);
142            return;
143        }
144        let payload = std::mem::replace(self, ReplyData::Empty);
145        *self = ReplyData::Rich(Box::new(ReplySpec {
146            payload,
147            status: Some(status),
148            headers: HashMap::new(),
149        }));
150    }
151
152    /// The value this reply will send for header `name`, or `None`. The
153    /// name matches case-insensitively.
154    ///
155    /// Follows the [`Finalizer`](crate::Finalizer): a header set on the reply
156    /// — by [`add_header`](Self::add_header), `reply!`'s `headers = { … }`,
157    /// [`build_reply`], or a constructor such as [`sse`] — wins; otherwise a
158    /// `Json` or `Bytes` payload's own `Content-Type` is reported. Headers the
159    /// server adds later in the pipeline (compression's `Content-Encoding`,
160    /// CORS, `Vary`, `Date`) are not visible here.
161    ///
162    /// The fill-in-a-default idiom for an `after` middleware:
163    ///
164    /// ```ignore
165    /// if response.header("cache-control").is_none() {
166    ///     response.add_header("Cache-Control", "private, no-store");
167    /// }
168    /// ```
169    pub fn header(&self, name: &str) -> Option<&str> {
170        match self {
171            // `last`, not `find`: the finalizer inserts entries in iteration
172            // order, so if a direct write to `ReplySpec::headers` left two
173            // spellings, the last match is the one it sends. (This relies on
174            // std's `HashMap` visiting `iter()` and `into_iter()` in the same
175            // order, which held for 1000 of 1000 maps on 2026-09-18.)
176            ReplyData::Rich(spec) => spec
177                .headers
178                .iter()
179                .filter(|(k, _)| k.eq_ignore_ascii_case(name))
180                .map(|(_, v)| v.as_str())
181                .last()
182                .or_else(|| spec.payload.header(name)),
183            ReplyData::Json(_) if name.eq_ignore_ascii_case("content-type") => {
184                Some("application/json")
185            }
186            ReplyData::Bytes { content_type, .. } if name.eq_ignore_ascii_case("content-type") => {
187                Some(content_type)
188            }
189            _ => None,
190        }
191    }
192}
193
194/// Set `name: value` in a reply's header map, first removing every entry whose
195/// name matches case-insensitively. Without this, `"Cache-Control"` and
196/// `"cache-control"` are two keys, and the [`Finalizer`](crate::Finalizer) —
197/// which `insert`s each into the response's `HeaderMap` — sends whichever it
198/// iterates last, which varies from one reply to the next.
199fn insert_header(headers: &mut HashMap<String, String>, name: String, value: String) {
200    headers.retain(|k, _| !k.eq_ignore_ascii_case(&name));
201    headers.insert(name, value);
202}
203
204// ===================== Simple Constructors =====================
205// All constructor functions must be `pub`.
206
207/// Build a JSON `ReplyData` from any [`Serialize`] value.
208///
209/// Panics on serialization failure. Most domain types (those with derived
210/// `Serialize` over stringy fields) never fail, but custom impls can.
211/// The `reply!` macro uses [`try_json`] internally to convert that case
212/// into a `WebError::Internal` (→ 500) rather than a handler-level panic
213/// that would drop the whole connection.
214///
215/// Prefer `reply!(my_value)` over calling this directly.
216pub fn json<T: Serialize>(val: T) -> ReplyData {
217    ReplyData::Json(serde_json::to_value(val).expect("serialize"))
218}
219
220/// Fallible counterpart of [`json`]. Returns the raw `serde_json::Error`
221/// on failure so callers (notably the `reply!` macro) can map it to a
222/// structured response instead of panicking.
223pub fn try_json<T: Serialize>(val: T) -> Result<ReplyData, serde_json::Error> {
224    Ok(ReplyData::Json(serde_json::to_value(val)?))
225}
226
227/// Build a `ReplyData::Bytes` with an explicit `Content-Type`. The
228/// content-type is required: there is no honest default for arbitrary
229/// bytes, and `application/octet-stream` masquerading as one tends to
230/// cause more bugs than it fixes (browsers guess; downstream HTTP
231/// caches mis-cache). Pass the right media type at the call site.
232///
233/// ```ignore
234/// reply::bytes("application/zip", zip_payload)
235/// reply::bytes("image/png", png_payload)
236/// ```
237pub fn bytes(content_type: impl Into<Cow<'static, str>>, data: impl Into<Vec<u8>>) -> ReplyData {
238    ReplyData::Bytes {
239        content_type: content_type.into(),
240        data: data.into(),
241    }
242}
243
244/// An empty-body reply (`204 No Content` unless a status is set via
245/// [`build_reply`]).
246pub fn empty() -> ReplyData {
247    ReplyData::Empty
248}
249
250/// Start a [`ReplySpec`] builder for a reply that needs an explicit status
251/// and/or headers on top of its body.
252pub fn build_reply() -> ReplySpec {
253    ReplySpec::new()
254}
255
256/// Build a streaming-body reply from a stream of byte chunks. The body is
257/// written out as the stream yields, not buffered.
258pub fn stream<S>(s: S) -> ReplyData
259where
260    S: Stream<Item = Result<Bytes, io::Error>> + Send + Sync + 'static,
261{
262    ReplyData::Stream(Box::pin(s))
263}
264
265// ===================== Server-Sent Events =====================
266
267/// One frame in a [Server-Sent Events][sse] stream. A frame may carry data
268/// (the common case), a comment (heartbeats and debug pings), and the
269/// optional `event` / `id` / `retry` fields. The wire encoding is handled
270/// by [`SseEvent::to_bytes`] — embedded newlines in `data` become multiple
271/// `data:` lines, the way the spec requires.
272///
273/// ```ignore
274/// SseEvent::data("hello").event("greeting").id("42")
275/// SseEvent::data("line1\nline2")  // becomes two `data: ...` lines
276/// SseEvent::comment("keep-alive")
277/// ```
278///
279/// [sse]: https://html.spec.whatwg.org/multipage/server-sent-events.html
280#[derive(Default, Debug, Clone)]
281pub struct SseEvent {
282    event: Option<String>,
283    id: Option<String>,
284    retry: Option<Duration>,
285    data: Option<String>,
286    comment: Option<String>,
287}
288
289impl SseEvent {
290    /// New event with a `data:` field. The most common entry point.
291    pub fn data(data: impl Into<String>) -> Self {
292        Self {
293            data: Some(data.into()),
294            ..Self::default()
295        }
296    }
297
298    /// New event with just a comment line (`: ...\n\n`). Comments are
299    /// ignored by `EventSource` clients but keep the connection alive
300    /// through intermediaries — useful as a heartbeat when the stream is
301    /// idle for long stretches.
302    pub fn comment(text: impl Into<String>) -> Self {
303        Self {
304            comment: Some(text.into()),
305            ..Self::default()
306        }
307    }
308
309    /// Set the event-type name (the `event:` field). Clients can route on
310    /// this with `EventSource.addEventListener(name, …)`. Embedded newlines
311    /// are replaced with spaces (the spec doesn't allow newlines in event
312    /// names).
313    pub fn event(mut self, name: impl Into<String>) -> Self {
314        self.event = Some(name.into());
315        self
316    }
317
318    /// Set the `id:` field. The browser will replay it on reconnect via
319    /// the `Last-Event-ID` request header — use it to support resumable
320    /// streams.
321    pub fn id(mut self, id: impl Into<String>) -> Self {
322        self.id = Some(id.into());
323        self
324    }
325
326    /// Set the `retry:` field — tells the client how long (in
327    /// milliseconds) to wait before reconnecting after a drop.
328    pub fn retry(mut self, d: Duration) -> Self {
329        self.retry = Some(d);
330        self
331    }
332
333    /// Encode this event in the SSE wire format, ending with the blank-line
334    /// frame separator (`\n`). Used by [`sse`]; you rarely need to call it
335    /// directly unless you're building a custom streaming pipeline.
336    pub fn to_bytes(&self) -> Bytes {
337        // Per HTML spec §9.2.6: lines starting with `:` are comments; each
338        // field is `field-name: value\n` (the leading space after the colon
339        // is consumed by the parser, so it's pretty but not significant);
340        // a blank line ends the event. `data` spanning multiple lines is
341        // expressed as one `data:` line per source line, then reassembled
342        // by the client with `\n` joins.
343        let mut out = String::new();
344        if let Some(event) = &self.event {
345            out.push_str("event: ");
346            out.push_str(&single_line(event));
347            out.push('\n');
348        }
349        if let Some(id) = &self.id {
350            out.push_str("id: ");
351            out.push_str(&single_line(id));
352            out.push('\n');
353        }
354        if let Some(retry) = self.retry {
355            // u128 → u64 saturate. >584M years of retry delay would
356            // overflow; nobody cares.
357            let ms = u64::try_from(retry.as_millis()).unwrap_or(u64::MAX);
358            out.push_str("retry: ");
359            out.push_str(&ms.to_string());
360            out.push('\n');
361        }
362        if let Some(comment) = &self.comment {
363            for line in comment.split('\n') {
364                out.push(':');
365                if !line.is_empty() {
366                    out.push(' ');
367                    out.push_str(line);
368                }
369                out.push('\n');
370            }
371        }
372        if let Some(data) = &self.data {
373            for line in data.split('\n') {
374                out.push_str("data: ");
375                out.push_str(line);
376                out.push('\n');
377            }
378        }
379        out.push('\n'); // blank line ends the event
380        Bytes::from(out)
381    }
382}
383
384/// Build a streaming Server-Sent Events response from a stream of
385/// [`SseEvent`]s. Sets `Content-Type: text/event-stream` and
386/// `Cache-Control: no-cache` (without which an intermediate cache might
387/// hold the live stream, defeating the point).
388///
389/// ```ignore
390/// use actus::prelude::*;
391/// use futures_util::stream;
392/// use std::time::Duration;
393///
394/// pub async fn updates(&self) -> Reply {
395///     let events = stream::iter(vec![
396///         SseEvent::data("tick").id("1"),
397///         SseEvent::data("tick").id("2").retry(Duration::from_secs(5)),
398///         SseEvent::comment("keep-alive"),
399///     ]);
400///     Ok(reply::sse(events))
401/// }
402/// ```
403///
404/// The stream is consumed lazily by the connection — frames are written
405/// out as the stream yields, not buffered. When the stream ends, the
406/// connection closes.
407pub fn sse<S>(events: S) -> ReplyData
408where
409    S: Stream<Item = SseEvent> + Send + Sync + 'static,
410{
411    let byte_stream = events.map(|ev| Ok::<Bytes, io::Error>(ev.to_bytes()));
412    let mut headers = HashMap::new();
413    headers.insert("content-type".to_string(), "text/event-stream".to_string());
414    headers.insert("cache-control".to_string(), "no-cache".to_string());
415    ReplyData::Rich(Box::new(ReplySpec {
416        payload: ReplyData::Stream(Box::pin(byte_stream)),
417        status: None,
418        headers,
419    }))
420}
421
422/// Replace `\n` and `\r` with `\x20` (space). For the `event` and `id`
423/// fields, where the SSE spec doesn't permit embedded newlines.
424fn single_line(s: &str) -> String {
425    s.replace(['\n', '\r'], " ")
426}
427
428// ===================== Builder Pattern =====================
429
430/// A builder for a reply with an explicit status and/or headers wrapped
431/// around a payload. Start one with [`build_reply`], chain the setters, and
432/// finish with [`ReplySpec::done`] (which yields a [`ReplyData::Rich`]).
433#[derive(Debug, PartialEq)]
434pub struct ReplySpec {
435    /// The body payload this spec decorates.
436    pub payload: ReplyData,
437    /// The status code to send, or `None` to use the payload's default.
438    pub status: Option<http::StatusCode>,
439    /// Extra response headers, as name → value. Prefer [`ReplySpec::header`]
440    /// and [`ReplyData::add_header`], which replace a header of the same name
441    /// in any letter case. Inserting here directly does not: two spellings of
442    /// one name would both reach the finalizer, and only one is sent.
443    pub headers: HashMap<String, String>,
444}
445
446impl Default for ReplySpec {
447    fn default() -> Self {
448        Self::new()
449    }
450}
451
452impl ReplySpec {
453    /// Start an empty spec (no body, no status, no headers).
454    pub fn new() -> Self {
455        Self {
456            payload: ReplyData::Empty,
457            status: None,
458            headers: HashMap::new(),
459        }
460    }
461
462    /// Set the body payload.
463    pub fn body(mut self, data: ReplyData) -> Self {
464        self.payload = data;
465        self
466    }
467
468    /// Set the response status code.
469    pub fn status(mut self, s: http::StatusCode) -> Self {
470        self.status = Some(s);
471        self
472    }
473
474    /// Add a response header, replacing any already set whose name matches
475    /// case-insensitively (see [`ReplyData::add_header`]).
476    pub fn header(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
477        insert_header(&mut self.headers, key.into(), value.into());
478        self
479    }
480
481    /// Finish the builder, producing a [`ReplyData::Rich`].
482    pub fn done(self) -> ReplyData {
483        ReplyData::Rich(Box::new(self))
484    }
485}
486
487// ===================== Ergonomic Macro =====================
488/// Creates a `Reply` (a `Result<ReplyData, WebError>`) for a success case.
489///
490/// Accepts any [`Serialize`] value; failures during JSON conversion become
491/// `Err(WebError::Internal(...))` (→ 500) rather than panicking. The
492/// underlying [`json`] function still panics for callers that prefer to
493/// surface bugs loudly; if you need that, call it directly.
494///
495/// ```ignore
496/// reply!(my_struct)                          // serialize as JSON
497/// reply!(json!({"status": "ok"}))            // inline JSON literal
498/// reply!()                                   // 204 No Content
499/// reply!(stream: byte_stream)                // streaming body
500/// reply!(sse: event_stream)                  // Server-Sent Events
501/// reply!(status = StatusCode::CREATED, value)
502/// reply!(
503///     status = StatusCode::CREATED,
504///     headers = { "Location": "/users/123" },
505///     value
506/// )
507/// ```
508#[macro_export]
509macro_rules! reply {
510    // With status and headers
511    (status = $status:expr, headers = { $($k:literal : $v:expr),* $(,)? }, $body:expr) => {{
512        match $crate::reply::try_json($body) {
513            Ok(__rd) => {
514                let mut spec = $crate::reply::build_reply()
515                    .status($status)
516                    .body(__rd);
517                $(
518                    spec = spec.header($k, &$v.to_string());
519                )*
520                Ok(spec.done())
521            }
522            Err(e) => Err($crate::reply::WebError::Internal(
523                ::std::format!("serialize response: {}", e),
524            )),
525        }
526    }};
527    // With just status
528    (status = $status:expr, $body:expr) => {
529        match $crate::reply::try_json($body) {
530            Ok(__rd) => Ok($crate::reply::build_reply()
531                .status($status)
532                .body(__rd)
533                .done()),
534            Err(e) => Err($crate::reply::WebError::Internal(
535                ::std::format!("serialize response: {}", e),
536            )),
537        }
538    };
539    // Stream
540    (stream: $s:expr) => {
541        Ok($crate::reply::stream($s))
542    };
543    // Server-Sent Events
544    (sse: $s:expr) => {
545        Ok($crate::reply::sse($s))
546    };
547    // Empty
548    () => {
549        Ok($crate::reply::empty())
550    };
551    // JSON from struct or literal
552    ($expr:expr) => {
553        match $crate::reply::try_json($expr) {
554            Ok(__rd) => Ok(__rd),
555            Err(e) => Err($crate::reply::WebError::Internal(
556                ::std::format!("serialize response: {}", e),
557            )),
558        }
559    };
560}
561
562// ===================== Error Type =====================
563
564/// Structured error response shaped like an [RFC 7807 Problem Details]
565/// document, plus arbitrary extension members that get serialized into
566/// the JSON body.
567///
568/// Use this when one of the simple `WebError::*` variants doesn't carry
569/// enough information (e.g., a validation failure that wants to name
570/// the failing field and rule).
571///
572/// ```ignore
573/// return Err(WebError::Problem(
574///     ProblemDetails::new(StatusCode::CONFLICT, "Validation")
575///         .detail("title is required")
576///         .extra("field", "data.title")
577///         .extra("rule", "required"),
578/// ));
579/// ```
580///
581/// Wire shape (`application/problem+json`):
582/// ```json
583/// { "status": 409, "title": "Validation", "detail": "title is required",
584///   "field": "data.title", "rule": "required" }
585/// ```
586///
587/// [RFC 7807 Problem Details]: https://datatracker.ietf.org/doc/html/rfc7807
588#[derive(Debug, Clone)]
589pub struct ProblemDetails {
590    /// The HTTP status code, serialized as the `status` member.
591    pub status: http::StatusCode,
592    /// A short, human-readable summary, serialized as the `title` member.
593    pub title: String,
594    /// An optional longer explanation, serialized as the `detail` member.
595    pub detail: Option<String>,
596    /// Extension members. Serialized alongside `status` / `title` / `detail`.
597    /// Keys that collide with the standard members are ignored.
598    ///
599    /// Boxed to keep `WebError` small: this map dominates the struct's size,
600    /// yet extensions are optional and most errors carry none — so the common
601    /// case pays one pointer, not a full inline map, on every `Result` return.
602    pub extra: Box<serde_json::Map<String, serde_json::Value>>,
603}
604
605impl ProblemDetails {
606    /// Construct a new problem with the given status code and title.
607    pub fn new(status: http::StatusCode, title: impl Into<String>) -> Self {
608        Self {
609            status,
610            title: title.into(),
611            detail: None,
612            extra: Box::new(serde_json::Map::new()),
613        }
614    }
615
616    /// Set the human-readable detail message.
617    pub fn detail(mut self, detail: impl Into<String>) -> Self {
618        self.detail = Some(detail.into());
619        self
620    }
621
622    /// Add an extension member. Use any JSON-serializable value via `.into()`,
623    /// `serde_json::json!(…)`, or `serde_json::to_value(…)`.
624    pub fn extra(mut self, key: impl Into<String>, value: impl Into<serde_json::Value>) -> Self {
625        self.extra.insert(key.into(), value.into());
626        self
627    }
628}
629
630impl fmt::Display for ProblemDetails {
631    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
632        match &self.detail {
633            Some(d) => write!(f, "{}: {}", self.title, d),
634            None => write!(f, "{}", self.title),
635        }
636    }
637}
638
639/// An error returned from a handler, a `prepare` hook, or middleware. Each
640/// variant maps to an HTTP status the framework renders as the response
641/// ([`WebError::Problem`] as an `application/problem+json` body).
642#[derive(thiserror::Error, Debug)]
643pub enum WebError {
644    /// `404 Not Found`.
645    #[error("Not found")]
646    NotFound,
647    /// `405`. Carries the methods the matched resource *does* accept, so the
648    /// response can include the `Allow` header RFC 7231 §6.5.5 requires.
649    /// Tokens are the canonical uppercase verb names (`"GET"`, `"POST"`, …);
650    /// `actus-server`'s router builds this list, so handlers rarely construct
651    /// it directly.
652    #[error("Method not allowed; allowed methods: {0:?}")]
653    MethodNotAllowed(Vec<&'static str>),
654    /// `400 Bad Request`. The string is a human-readable reason (e.g. a
655    /// malformed-body or missing-parameter explanation).
656    #[error("Bad request: {0}")]
657    BadRequest(String),
658    /// `413`. The request body exceeded the configured limit (see
659    /// `Server::with_max_body_bytes`).
660    #[error("Payload too large")]
661    PayloadTooLarge,
662    /// `429`. Rate limit exceeded. The optional [`Duration`] is the
663    /// retry-after hint — when present, the framework sets a
664    /// `Retry-After: <seconds>` header on the response, per RFC 7231
665    /// §7.1.3. Used by an application-supplied rate-limit middleware /
666    /// prepare hook; the framework itself doesn't ship a limiter (policy
667    /// belongs in the application; see the rate-limiting pattern in the
668    /// README).
669    #[error("Too many requests")]
670    TooManyRequests(Option<Duration>),
671    /// `504`. The request didn't complete within the configured timeout
672    /// (see `Server::with_request_timeout`). Mapped to `504 Gateway
673    /// Timeout`: the framework is acting as a gateway between the HTTP
674    /// connection and the handler/middleware stack, and the upstream
675    /// (handler) didn't respond in time. Produced by the framework when
676    /// the per-request timer elapses; handlers can also `Err(Timeout)`
677    /// from a downstream call that timed out themselves.
678    #[error("Request timeout")]
679    Timeout,
680    /// `503`. The server is overloaded — typically the framework-level
681    /// concurrency / buffer budget is exhausted (see
682    /// `Server::with_max_inflight_body_bytes`). The optional [`Duration`]
683    /// is a retry-after hint; when present, the framework sets a
684    /// `Retry-After: <seconds>` header on the response. Distinct from
685    /// `TooManyRequests` (429): `Busy` is "the server is overwhelmed
686    /// globally," not "this client has hit its specific limit."
687    #[error("Server busy")]
688    Busy(Option<Duration>),
689    /// `401 Unauthorized` — authentication is missing or invalid.
690    #[error("Unauthorized")]
691    Unauthorized,
692    /// `403 Forbidden` — authenticated but not permitted.
693    #[error("Forbidden")]
694    Forbidden,
695    /// `500 Internal Server Error`. The string is logged/returned as the
696    /// reason; use it for unexpected failures, not for client mistakes.
697    #[error("Internal error: {0}")]
698    Internal(String),
699    /// Structured error with an explicit status code, title, optional detail,
700    /// and arbitrary extension members. Renders as `application/problem+json`.
701    #[error("{0}")]
702    Problem(ProblemDetails),
703}
704
705// ===================== Tests =====================
706#[cfg(test)]
707mod tests {
708    // Note: bring everything from the parent module into scope for tests
709    use super::*;
710    use futures_util::{StreamExt, stream};
711    use http::StatusCode;
712    use serde_json::json;
713
714    // Test that the macro now correctly returns a Result
715    #[test]
716    fn test_macro_returns_result() {
717        let success_reply: Reply = reply!(json!({"status": "ok"}));
718        assert!(success_reply.is_ok());
719
720        let empty_reply: Reply = reply!();
721        assert!(empty_reply.is_ok());
722    }
723
724    #[test]
725    fn test_constructors() {
726        matches!(json(json!({"ok": true})), ReplyData::Json(_));
727        matches!(
728            bytes("application/octet-stream", vec![0, 1]),
729            ReplyData::Bytes { .. }
730        );
731        matches!(empty(), ReplyData::Empty);
732    }
733
734    #[tokio::test]
735    async fn test_stream_constructor() {
736        let test_stream = stream::once(async { Ok::<_, io::Error>(Bytes::from("test")) });
737        let reply_data = stream(test_stream);
738        if let ReplyData::Stream(mut s) = reply_data {
739            assert_eq!(s.next().await.unwrap().unwrap(), "test");
740        } else {
741            panic!("Expected a stream reply");
742        }
743    }
744
745    #[test]
746    fn test_reply_macro_variants() {
747        // JSON literal
748        let data_res: Reply = reply!(json!({"a":1}));
749        matches!(data_res.unwrap(), ReplyData::Json(_));
750
751        // Struct
752        #[derive(Serialize)]
753        struct TestStruct {
754            val: i32,
755        }
756        let data_res: Reply = reply!(TestStruct { val: 10 });
757        matches!(data_res.unwrap(), ReplyData::Json(_));
758
759        // Empty
760        let data_res: Reply = reply!();
761        matches!(data_res.unwrap(), ReplyData::Empty);
762
763        // With status
764        let data_res: Reply = reply!(status = StatusCode::CREATED, json!({"id": 1}));
765        match data_res.unwrap() {
766            ReplyData::Rich(spec) => {
767                assert_eq!(spec.status, Some(StatusCode::CREATED));
768            }
769            _ => panic!("Expected Rich reply"),
770        }
771
772        // With status and headers
773        let data_res: Reply =
774            reply!(status = StatusCode::CREATED, headers = {"X-Test": "value"}, json!({"id": 1}));
775        match data_res.unwrap() {
776            ReplyData::Rich(spec) => {
777                assert_eq!(spec.headers.get("X-Test"), Some(&"value".to_string()));
778            }
779            _ => panic!("Expected Rich reply"),
780        }
781    }
782
783    #[test]
784    fn add_header_lifts_non_rich_into_rich_preserving_payload() {
785        // Json → Rich(Json), header set.
786        let mut r = ReplyData::Json(json!({"x": 1}));
787        r.add_header("X-Trace-Id", "abc");
788        match &r {
789            ReplyData::Rich(spec) => {
790                assert!(matches!(spec.payload, ReplyData::Json(_)));
791                assert_eq!(spec.headers.get("X-Trace-Id"), Some(&"abc".to_string()));
792                assert_eq!(spec.status, None);
793            }
794            _ => panic!("expected Rich, got {r:?}"),
795        }
796
797        // Empty → Rich(Empty), header set.
798        let mut r = ReplyData::Empty;
799        r.add_header("X-K", "v");
800        assert!(matches!(&r, ReplyData::Rich(s) if matches!(s.payload, ReplyData::Empty)));
801
802        // Already Rich → mutate in place, payload untouched.
803        let mut r = ReplyData::Rich(Box::new(ReplySpec {
804            payload: ReplyData::Json(json!({"y": 2})),
805            status: Some(StatusCode::CREATED),
806            headers: HashMap::from([("Existing".into(), "1".into())]),
807        }));
808        r.add_header("New", "2");
809        match &r {
810            ReplyData::Rich(spec) => {
811                assert_eq!(spec.status, Some(StatusCode::CREATED));
812                assert_eq!(spec.headers.get("Existing"), Some(&"1".to_string()));
813                assert_eq!(spec.headers.get("New"), Some(&"2".to_string()));
814            }
815            _ => unreachable!(),
816        }
817    }
818
819    #[test]
820    fn set_status_lifts_or_mutates() {
821        let mut r = ReplyData::Json(json!({"x": 1}));
822        r.set_status(StatusCode::ACCEPTED);
823        match &r {
824            ReplyData::Rich(spec) => {
825                assert_eq!(spec.status, Some(StatusCode::ACCEPTED));
826                assert!(matches!(spec.payload, ReplyData::Json(_)));
827            }
828            _ => panic!("expected Rich"),
829        }
830
831        // Already Rich: mutate.
832        let mut r = ReplyData::Rich(Box::new(ReplySpec {
833            payload: ReplyData::Empty,
834            status: None,
835            headers: HashMap::new(),
836        }));
837        r.set_status(StatusCode::NOT_FOUND);
838        if let ReplyData::Rich(spec) = &r {
839            assert_eq!(spec.status, Some(StatusCode::NOT_FOUND));
840        }
841    }
842
843    #[test]
844    fn add_header_and_set_status_are_a_noop_on_upgrade() {
845        let task: Box<dyn std::any::Any + Send> = Box::new(42i32); // any `Any+Send` is fine for this test
846        let mut r = ReplyData::Upgrade(task);
847        r.add_header("X-K", "v");
848        r.set_status(StatusCode::CREATED);
849        assert!(matches!(r, ReplyData::Upgrade(_))); // unchanged
850    }
851
852    // ===== Header names are case-insensitive (RFC 9110 §5.1) =====
853    //
854    // Two spellings of one name used to be two map keys, and the finalizer
855    // sent whichever it iterated last — a different one from reply to reply.
856
857    #[test]
858    fn add_header_replaces_a_header_of_any_case() {
859        let mut r = build_reply()
860            .header("Cache-Control", "private, max-age=60")
861            .body(ReplyData::Empty)
862            .done();
863        r.add_header("cache-control", "no-store");
864        let ReplyData::Rich(spec) = &r else {
865            panic!("expected Rich, got {r:?}");
866        };
867        assert_eq!(spec.headers.len(), 1, "{:?}", spec.headers);
868        assert_eq!(
869            spec.headers.get("cache-control"),
870            Some(&"no-store".to_string())
871        );
872    }
873
874    #[test]
875    fn builder_header_replaces_a_header_of_any_case() {
876        let r = build_reply()
877            .header("X-Request-Id", "a")
878            .header("x-request-id", "b")
879            .done();
880        let ReplyData::Rich(spec) = &r else {
881            panic!("expected Rich, got {r:?}");
882        };
883        assert_eq!(spec.headers.len(), 1, "{:?}", spec.headers);
884        assert_eq!(spec.headers.get("x-request-id"), Some(&"b".to_string()));
885    }
886
887    #[test]
888    fn header_lookup_is_case_insensitive_and_sees_what_the_reply_will_send() {
889        // `sse` stores lowercase keys; any spelling finds them.
890        let events = sse(stream::iter(Vec::<SseEvent>::new()));
891        assert_eq!(events.header("Cache-Control"), Some("no-cache"));
892        // Still visible once wrapped again — the finalizer applies the inner
893        // reply's headers, then the outer ones.
894        let wrapped = build_reply()
895            .status(StatusCode::OK)
896            .body(sse(stream::iter(Vec::<SseEvent>::new())))
897            .done();
898        assert_eq!(wrapped.header("cache-control"), Some("no-cache"));
899        // A payload's own Content-Type is reported…
900        assert_eq!(
901            json(json!({})).header("content-type"),
902            Some("application/json")
903        );
904        assert_eq!(
905            bytes("image/png", vec![0]).header("Content-Type"),
906            Some("image/png")
907        );
908        // …unless the reply sets one, which then wins.
909        let explicit = build_reply()
910            .header("Content-Type", "application/vnd.example+json")
911            .body(json(json!({})))
912            .done();
913        assert_eq!(
914            explicit.header("content-type"),
915            Some("application/vnd.example+json")
916        );
917        // Nothing set and nothing implied.
918        assert_eq!(json(json!({})).header("cache-control"), None);
919        assert_eq!(ReplyData::Empty.header("content-type"), None);
920        let task: Box<dyn std::any::Any + Send> = Box::new(0u8);
921        assert_eq!(ReplyData::Upgrade(task).header("content-type"), None);
922    }
923
924    // ===== SSE encoding =====
925    //
926    // Each test pins one bit of the W3C wire spec. Decode-side reassembly
927    // would be the obvious sanity check, but a `tokio-tungstenite`-style
928    // SSE-parser dep isn't worth pulling in for what amounts to "did we
929    // emit the right bytes" — we read the encoded string directly.
930
931    fn s(b: &Bytes) -> &str {
932        std::str::from_utf8(b).expect("SSE encoding is always valid UTF-8")
933    }
934
935    #[test]
936    fn sse_data_only_event_emits_one_data_line_and_blank_separator() {
937        let bytes = SseEvent::data("hello").to_bytes();
938        assert_eq!(s(&bytes), "data: hello\n\n");
939    }
940
941    #[test]
942    fn sse_multi_line_data_emits_one_data_line_per_source_line() {
943        // The browser reassembles by `\n`-joining the values, so a source
944        // string "a\nb\nc" must round-trip as the same string.
945        let bytes = SseEvent::data("a\nb\nc").to_bytes();
946        assert_eq!(s(&bytes), "data: a\ndata: b\ndata: c\n\n");
947    }
948
949    #[test]
950    fn sse_event_id_retry_data_all_in_one_frame() {
951        let bytes = SseEvent::data("payload")
952            .event("update")
953            .id("17")
954            .retry(Duration::from_millis(2500))
955            .to_bytes();
956        assert_eq!(
957            s(&bytes),
958            "event: update\nid: 17\nretry: 2500\ndata: payload\n\n",
959        );
960    }
961
962    #[test]
963    fn sse_event_and_id_strip_embedded_newlines() {
964        // The spec doesn't allow `\n` in single-line fields. We replace
965        // with a space rather than panic / reject — the value still
966        // travels, the field stays well-formed.
967        let bytes = SseEvent::data("ok").event("a\nb").id("x\ry").to_bytes();
968        assert_eq!(s(&bytes), "event: a b\nid: x y\ndata: ok\n\n");
969    }
970
971    #[test]
972    fn sse_comment_only_event_is_a_heartbeat() {
973        // Multi-line comments: each source line becomes its own `:` line.
974        // Empty comment lines emit a bare `:\n` (still a valid comment).
975        let single = SseEvent::comment("keep-alive").to_bytes();
976        assert_eq!(s(&single), ": keep-alive\n\n");
977        let multi = SseEvent::comment("line1\n\nline3").to_bytes();
978        assert_eq!(s(&multi), ": line1\n:\n: line3\n\n");
979    }
980
981    #[test]
982    fn sse_empty_data_string_still_emits_a_data_field() {
983        // `data("")` is explicit "send an empty data field"; absent data
984        // (a comment-only event) skips the field entirely. The previous
985        // test covers absent.
986        let bytes = SseEvent::data("").to_bytes();
987        assert_eq!(s(&bytes), "data: \n\n");
988    }
989
990    #[tokio::test]
991    async fn sse_stream_wraps_in_rich_with_correct_headers_and_streams_each_event() {
992        use futures_util::stream;
993        let events = stream::iter(vec![
994            SseEvent::data("first").id("1"),
995            SseEvent::data("second").event("update"),
996        ]);
997        let reply = sse(events);
998        let ReplyData::Rich(spec) = reply else {
999            panic!("sse() produces a Rich reply for the headers");
1000        };
1001        assert_eq!(
1002            spec.headers.get("content-type"),
1003            Some(&"text/event-stream".to_string())
1004        );
1005        assert_eq!(
1006            spec.headers.get("cache-control"),
1007            Some(&"no-cache".to_string())
1008        );
1009
1010        let ReplyData::Stream(byte_stream) = spec.payload else {
1011            panic!("sse() payload is a Stream of bytes");
1012        };
1013        // Drain the stream and concat to verify the wire framing of the
1014        // whole conversation.
1015        let chunks: Vec<Bytes> = byte_stream
1016            .map(|r| r.expect("infallible map"))
1017            .collect()
1018            .await;
1019        let joined: String = chunks
1020            .iter()
1021            .map(|b| std::str::from_utf8(b).unwrap())
1022            .collect();
1023        assert_eq!(
1024            joined,
1025            "id: 1\ndata: first\n\nevent: update\ndata: second\n\n",
1026        );
1027    }
1028
1029    #[test]
1030    fn test_builder_pattern() {
1031        let data = build_reply()
1032            .status(StatusCode::ACCEPTED)
1033            .header("X-Custom", "value123")
1034            .body(bytes("application/zip", vec![1, 2, 3]))
1035            .done();
1036
1037        match data {
1038            ReplyData::Rich(spec) => {
1039                assert_eq!(spec.status, Some(StatusCode::ACCEPTED));
1040                assert_eq!(spec.headers.get("X-Custom"), Some(&"value123".to_string()));
1041                assert_eq!(
1042                    spec.payload,
1043                    ReplyData::Bytes {
1044                        content_type: Cow::Borrowed("application/zip"),
1045                        data: vec![1, 2, 3],
1046                    }
1047                );
1048            }
1049            _ => panic!("Expected Rich reply"),
1050        }
1051    }
1052}