Skip to main content

webx_core/
action.rs

1//! The `IActionResult` family — results that write the HTTP response themselves.
2//!
3//! A normal endpoint returns a serializable value: the framework serializes it
4//! as JSON and answers `200`. When an action must control the response — a file
5//! download, a non-JSON body, a different status code, extra headers — it
6//! returns an **action result** instead, mirroring ASP.NET Core:
7//!
8//! ```ignore
9//! #[get("/reports/{id}")]
10//! impl IRequest<Response> for DownloadReportRequest {}
11//!
12//! impl IRequestHandler<DownloadReportRequest, Response> for DownloadReportHandler {
13//!     async fn handle(&mut self, req: DownloadReportRequest) -> ActionResult<Response> {
14//!         Ok(Box::new(
15//!             FileResult::path(self.reports.path_for(&req.id)?)
16//!                 .content_type("text/csv")
17//!                 .download_name("report.csv"),
18//!         ))
19//!     }
20//! }
21//! ```
22//!
23//! [`Response`] is the response type for such an action: `Box<dyn IActionResult>`
24//! (Rust needs the box; ASP.NET Core writes `IActionResult` directly). A value
25//! that needs no response control stays a plain DTO and never touches this
26//! module.
27//!
28//! Result builders accept a raw `u16` status and arbitrary header names. Both
29//! are validated by the transport when the response is finalised: an invalid
30//! status line or header value degrades to a `500 Internal Server Error` instead
31//! of failing to encode. Use the constants from `http` (or `200`/`201`/`204`)
32//! to avoid that path.
33
34use crate::error::Result;
35use crate::http::{content_disposition_value, Disposition, FileBody, IHttpContext, ResponseBody};
36use serde::Serialize;
37
38/// ASP.NET Core's `IActionResult`: the action writes the response directly.
39///
40/// Implement this when none of the built-in results fit; return it boxed as
41/// [`Response`]. The transport applies whatever the result sets — status,
42/// headers, byte body or file body (the host then serves ranges, validators and
43/// chunked streams for file bodies).
44#[async_trait::async_trait]
45pub trait IActionResult: Send {
46    /// Write this result into the response.
47    async fn execute(self: Box<Self>, ctx: &mut dyn IHttpContext) -> Result<()>;
48}
49
50/// The response type of an endpoint that controls the HTTP response itself:
51/// ASP.NET Core's `IActionResult` return type.
52pub type Response = Box<dyn IActionResult>;
53
54/// `200 OK` + a JSON body — the result the framework uses for ordinary
55/// `Serialize` DTO responses.
56pub struct JsonResult<T> {
57    /// The value written as `application/json`.
58    pub value: T,
59    /// HTTP status to use instead of `200`.
60    pub status: u16,
61}
62
63impl<T> JsonResult<T> {
64    /// A `200 OK` JSON result.
65    pub fn new(value: T) -> Self {
66        Self { value, status: 200 }
67    }
68
69    /// Override the HTTP status (e.g. `201` after a create).
70    pub fn status(mut self, status: u16) -> Self {
71        self.status = status;
72        self
73    }
74}
75
76#[async_trait::async_trait]
77impl<T: Serialize + Send> IActionResult for JsonResult<T> {
78    async fn execute(self: Box<Self>, ctx: &mut dyn IHttpContext) -> Result<()> {
79        // Serialize before touching the response: the body must be a Send
80        // buffer, and the DTO itself does not need to be `Sync` to be written.
81        let json =
82            serde_json::to_vec(&self.value).map_err(crate::error::Error::ResponseSerialization)?;
83        let response = ctx.response_mut();
84        response.set_status(self.status);
85        response.set_header("content-type", "application/json");
86        response.write_bytes(json).await
87    }
88}
89
90/// A body sent as-is with an explicit media type — ASP.NET Core's
91/// `ContentResult` (`Content(...)`, `Text(...)`, `Results.Bytes(...)`).
92pub struct ContentResult {
93    body: Vec<u8>,
94    content_type: String,
95    status: u16,
96    headers: Vec<(String, String)>,
97}
98
99impl ContentResult {
100    /// UTF-8 `text/plain; charset=utf-8`.
101    pub fn text(body: impl Into<String>) -> Self {
102        Self::bytes(body.into().into_bytes(), "text/plain; charset=utf-8")
103    }
104
105    /// UTF-8 `text/html; charset=utf-8`.
106    pub fn html(body: impl Into<String>) -> Self {
107        Self::bytes(body.into().into_bytes(), "text/html; charset=utf-8")
108    }
109
110    /// Raw bytes with an explicit media type.
111    pub fn bytes(body: impl Into<Vec<u8>>, content_type: impl Into<String>) -> Self {
112        Self {
113            body: body.into(),
114            content_type: content_type.into(),
115            status: 200,
116            headers: Vec::new(),
117        }
118    }
119
120    /// Override the media type (e.g. `text/csv; charset=utf-8`).
121    pub fn content_type(mut self, content_type: impl Into<String>) -> Self {
122        self.content_type = content_type.into();
123        self
124    }
125
126    /// Override the HTTP status.
127    pub fn status(mut self, status: u16) -> Self {
128        self.status = status;
129        self
130    }
131
132    /// Add a response header.
133    pub fn header(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
134        self.headers.push((key.into(), value.into()));
135        self
136    }
137
138    /// Send the body as a download named `file_name`
139    /// (`Content-Disposition: attachment`).
140    pub fn download_name(self, file_name: impl Into<String>) -> Self {
141        self.with_disposition(Disposition::Attachment, file_name.into())
142    }
143
144    /// Send the body inline under `file_name`
145    /// (`Content-Disposition: inline`).
146    pub fn inline_name(self, file_name: impl Into<String>) -> Self {
147        self.with_disposition(Disposition::Inline, file_name.into())
148    }
149
150    fn with_disposition(mut self, disposition: Disposition, file_name: String) -> Self {
151        self.headers.push((
152            "content-disposition".into(),
153            content_disposition_value(disposition, &file_name),
154        ));
155        self
156    }
157}
158
159#[async_trait::async_trait]
160impl IActionResult for ContentResult {
161    async fn execute(self: Box<Self>, ctx: &mut dyn IHttpContext) -> Result<()> {
162        let response = ctx.response_mut();
163        response.set_status(self.status);
164        response.set_header("content-type", &self.content_type);
165        for (key, value) in &self.headers {
166            response.set_header(key, value);
167        }
168        response.write_bytes(self.body).await
169    }
170}
171
172/// `204 No Content`.
173pub struct NoContentResult;
174
175#[async_trait::async_trait]
176impl IActionResult for NoContentResult {
177    async fn execute(self: Box<Self>, ctx: &mut dyn IHttpContext) -> Result<()> {
178        ctx.response_mut().set_status(204);
179        Ok(())
180    }
181}
182
183/// A status code with no body (e.g. `202 Accepted`).
184pub struct StatusCodeResult(pub u16);
185
186#[async_trait::async_trait]
187impl IActionResult for StatusCodeResult {
188    async fn execute(self: Box<Self>, ctx: &mut dyn IHttpContext) -> Result<()> {
189        ctx.response_mut().set_status(self.0);
190        Ok(())
191    }
192}
193
194/// A file response — ASP.NET Core's `PhysicalFile(...)` / `File(...)` /
195/// `Results.File(...)`.
196///
197/// The host streams the source and fills in the HTTP metadata: `Content-Length`,
198/// `ETag` / `Last-Modified` validators, `Accept-Ranges` and `Range` handling
199/// (`206` / `416`), plus conditional requests (`304`). Files opened from a path
200/// derive their validators from size and mtime; streams become conditional when
201/// an [`entity_tag`](FileResult::entity_tag) or
202/// [`last_modified`](FileResult::last_modified) is supplied.
203pub struct FileResult {
204    body: FileBody,
205    status: u16,
206    headers: Vec<(String, String)>,
207}
208
209impl FileResult {
210    /// Send the file at `path` (ASP.NET Core's `PhysicalFile`).
211    pub fn path(path: impl Into<std::path::PathBuf>) -> Self {
212        Self::with_body(FileBody::path(path))
213    }
214
215    /// Send an async reader of unknown length (chunked, no `Range`).
216    pub fn stream(reader: impl tokio::io::AsyncRead + Send + Unpin + 'static) -> Self {
217        Self::with_body(FileBody::stream(reader))
218    }
219
220    /// Send an async reader of known length that supports seeking, so clients
221    /// can resume and cache (`Content-Length`, `Range`).
222    pub fn seekable_stream(
223        reader: impl tokio::io::AsyncRead + tokio::io::AsyncSeek + Send + Unpin + 'static,
224        len: u64,
225    ) -> Self {
226        Self::with_body(FileBody::seekable_stream(reader, len))
227    }
228
229    /// Send a fully-specified [`FileBody`].
230    pub fn with_body(body: FileBody) -> Self {
231        Self {
232            body,
233            status: 200,
234            headers: Vec::new(),
235        }
236    }
237
238    /// Override the media type instead of deriving it from the extension.
239    pub fn content_type(mut self, content_type: impl Into<String>) -> Self {
240        self.body = self.body.content_type(content_type);
241        self
242    }
243
244    /// Send the file as a download named `file_name`.
245    pub fn download_name(mut self, file_name: impl Into<String>) -> Self {
246        self.body = self.body.download_name(file_name);
247        self
248    }
249
250    /// Present the file inline under `file_name`.
251    pub fn inline_name(mut self, file_name: impl Into<String>) -> Self {
252        self.body = self.body.inline_name(file_name);
253        self
254    }
255
256    /// Enable or disable `Range` processing for the file source.
257    pub fn enable_range_processing(mut self, enabled: bool) -> Self {
258        self.body = self.body.enable_range_processing(enabled);
259        self
260    }
261
262    /// Advertise an explicit `Last-Modified` (required for a stream to support
263    /// `If-Modified-Since`).
264    pub fn last_modified(mut self, last_modified: std::time::SystemTime) -> Self {
265        self.body = self.body.last_modified(last_modified);
266        self
267    }
268
269    /// Advertise an explicit `ETag` (required for a stream to support
270    /// `If-None-Match`).
271    pub fn entity_tag(mut self, entity_tag: impl Into<String>) -> Self {
272        self.body = self.body.entity_tag(entity_tag);
273        self
274    }
275
276    /// Override the HTTP status.
277    pub fn status(mut self, status: u16) -> Self {
278        self.status = status;
279        self
280    }
281
282    /// Add a response header.
283    pub fn header(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
284        self.headers.push((key.into(), value.into()));
285        self
286    }
287}
288
289#[async_trait::async_trait]
290impl IActionResult for FileResult {
291    async fn execute(self: Box<Self>, ctx: &mut dyn IHttpContext) -> Result<()> {
292        let response = ctx.response_mut();
293        response.set_status(self.status);
294        for (key, value) in &self.headers {
295            response.set_header(key, value);
296        }
297        response.write_body(ResponseBody::File(self.body)).await
298    }
299}