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