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}