Skip to content

Commit c6ec025

Browse files
committed
support redirecting from extractors
1 parent da72b63 commit c6ec025

File tree

8 files changed

+214
-20
lines changed

8 files changed

+214
-20
lines changed

packages/core/src/control_flow.rs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
use std::fmt;
2+
3+
/// Marker error type used to indicate an error is expected control-flow (not an actual failure).
4+
///
5+
/// This is intentionally defined in `dioxus-core` so integrations (like fullstack) can attach it as a
6+
/// `source()` to their own error types without introducing dependency cycles.
7+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
8+
#[doc(hidden)]
9+
pub struct RedirectControlFlow;
10+
11+
impl fmt::Display for RedirectControlFlow {
12+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
13+
f.write_str("redirect control-flow")
14+
}
15+
}
16+
17+
impl std::error::Error for RedirectControlFlow {}
18+
19+

packages/core/src/lib.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ mod effect;
1010
mod error_boundary;
1111
mod events;
1212
mod fragment;
13+
mod control_flow;
1314
mod generational_box;
1415
mod global_context;
1516
mod launch;
@@ -30,6 +31,11 @@ mod virtual_dom;
3031

3132
mod hotreload_utils;
3233

34+
// This is intentionally public (but hidden from docs) so integration crates can use it without
35+
// creating dependency cycles back into core.
36+
#[doc(hidden)]
37+
pub use control_flow::RedirectControlFlow;
38+
3339
/// Items exported from this module are used in macros and should not be used directly.
3440
#[doc(hidden)]
3541
pub mod internal {

packages/core/src/scope_arena.rs

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,20 @@ impl VirtualDom {
108108
fn handle_element_return(&self, node: &mut Element, scope: &Scope) {
109109
match node {
110110
Err(RenderError::Error(e)) => {
111-
tracing::error!("Error while rendering component `{}`: {e}", scope.name);
111+
// Redirects are expected control-flow in some integrations (like fullstack SSR)
112+
// and shouldn't be logged as errors.
113+
//
114+
// We detect these via a core-owned marker error (`RedirectControlFlow`) attached
115+
// as a `source()` by integration crates to avoid dependency cycles.
116+
let is_redirect = e
117+
.chain()
118+
.any(|cause| cause.is::<crate::RedirectControlFlow>());
119+
120+
if is_redirect {
121+
tracing::info!("Redirect while rendering component `{}`: {e}", scope.name);
122+
} else {
123+
tracing::error!("Error while rendering component `{}`: {e}", scope.name);
124+
}
112125
self.runtime.throw_error(scope.id, e.clone());
113126
}
114127
Err(RenderError::Suspended(e)) => {

packages/fullstack-core/src/error.rs

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,23 @@ pub enum ServerFnError {
8181
/// Occurs on the server if there is an error creating an HTTP response.
8282
#[error("error creating response {0}")]
8383
Response(String),
84+
85+
/// A redirect response returned while running a server function or extractor.
86+
///
87+
/// This is treated as control-flow rather than an ordinary error so we can preserve the
88+
/// `Location` header (which is otherwise lost when converting axum responses into `ServerFnError`).
89+
#[error("redirect ({code}) to {location}")]
90+
Redirect {
91+
/// HTTP status code associated with the redirect (typically 302/303/307/308).
92+
code: u16,
93+
/// The value of the `Location` header.
94+
location: String,
95+
/// Marker source to let core treat this as expected control-flow (not an error log).
96+
#[doc(hidden)]
97+
#[serde(skip, default)]
98+
#[source]
99+
control_flow: dioxus_core::RedirectControlFlow,
100+
},
84101
}
85102

86103
impl ServerFnError {
@@ -93,6 +110,15 @@ impl ServerFnError {
93110
}
94111
}
95112

113+
/// Create a redirect error (control-flow) with a status code and `Location`.
114+
pub fn redirect(code: u16, location: impl Into<String>) -> Self {
115+
ServerFnError::Redirect {
116+
code,
117+
location: location.into(),
118+
control_flow: dioxus_core::RedirectControlFlow::default(),
119+
}
120+
}
121+
96122
/// Create a new server error (status code 500) with a message and details.
97123
pub async fn from_axum_response(resp: axum_core::response::Response) -> Self {
98124
let status = resp.status();
@@ -138,6 +164,9 @@ impl From<ServerFnError> for http::StatusCode {
138164
ServerFnError::ServerError { code, .. } => {
139165
http::StatusCode::from_u16(code).unwrap_or(http::StatusCode::INTERNAL_SERVER_ERROR)
140166
}
167+
ServerFnError::Redirect { code, .. } => {
168+
http::StatusCode::from_u16(code).unwrap_or(http::StatusCode::INTERNAL_SERVER_ERROR)
169+
}
141170
ServerFnError::Request(err) => match err {
142171
RequestError::Status(_, code) => http::StatusCode::from_u16(code)
143172
.unwrap_or(http::StatusCode::INTERNAL_SERVER_ERROR),
@@ -166,6 +195,7 @@ impl From<ServerFnError> for HttpError {
166195
fn from(value: ServerFnError) -> Self {
167196
let status = StatusCode::from_u16(match &value {
168197
ServerFnError::ServerError { code, .. } => *code,
198+
ServerFnError::Redirect { code, .. } => *code,
169199
_ => 500,
170200
})
171201
.unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
@@ -195,6 +225,23 @@ impl From<HttpError> for ServerFnError {
195225
impl IntoResponse for ServerFnError {
196226
fn into_response(self) -> axum_core::response::Response {
197227
match self {
228+
Self::Redirect { code, location, .. } => {
229+
use http::header::LOCATION;
230+
let status =
231+
StatusCode::from_u16(code).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
232+
axum_core::response::Response::builder()
233+
.status(status)
234+
.header(LOCATION, location)
235+
.body(axum_core::body::Body::empty())
236+
.unwrap_or_else(|_| {
237+
axum_core::response::Response::builder()
238+
.status(StatusCode::INTERNAL_SERVER_ERROR)
239+
.body(axum_core::body::Body::from(
240+
"{\"error\":\"Internal Server Error\"}",
241+
))
242+
.unwrap()
243+
})
244+
}
198245
Self::ServerError {
199246
message,
200247
code,

packages/fullstack-core/src/streaming.rs

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ use crate::{HttpError, ServerFnError};
22
use axum_core::extract::FromRequest;
33
use axum_core::response::IntoResponse;
44
use dioxus_core::{CapturedError, ReactiveContext};
5+
use http::header::LOCATION;
56
use http::StatusCode;
67
use http::{request::Parts, HeaderMap};
78
use parking_lot::RwLock;
@@ -146,6 +147,19 @@ impl FullstackContext {
146147
Ok(res) => Ok(res),
147148
Err(err) => {
148149
let resp = err.into_response();
150+
151+
// Preserve redirects from axum-style extractors (3xx + Location) as control-flow.
152+
// If we collapse this response into `ServerFnError::ServerError`, we lose headers like
153+
// `Location` and redirects silently stop working.
154+
let status = resp.status();
155+
if status.is_redirection() {
156+
if let Some(location) = resp.headers().get(LOCATION) {
157+
if let Ok(location) = location.to_str() {
158+
return Err(ServerFnError::redirect(status.as_u16(), location));
159+
}
160+
}
161+
}
162+
149163
Err(ServerFnError::from_axum_response(resp).await)
150164
}
151165
}
@@ -336,3 +350,60 @@ pub fn status_code_from_error(error: &CapturedError) -> StatusCode {
336350

337351
StatusCode::INTERNAL_SERVER_ERROR
338352
}
353+
354+
#[cfg(test)]
355+
mod tests {
356+
use super::*;
357+
use axum_core::extract::{FromRequest, Request};
358+
use http::header::LOCATION;
359+
360+
#[derive(Debug)]
361+
struct RedirectingExtractor;
362+
363+
impl FromRequest<FullstackContext, ()> for RedirectingExtractor {
364+
type Rejection = (StatusCode, HeaderMap);
365+
366+
fn from_request(
367+
_req: Request,
368+
_state: &FullstackContext,
369+
) -> impl std::future::Future<Output = Result<Self, Self::Rejection>> + Send {
370+
async move {
371+
let mut headers = HeaderMap::new();
372+
headers.insert(LOCATION, http::HeaderValue::from_static("/sign-in"));
373+
Err((StatusCode::TEMPORARY_REDIRECT, headers))
374+
}
375+
}
376+
}
377+
378+
#[test]
379+
fn extract_preserves_redirect_location() {
380+
let rt = tokio::runtime::Builder::new_current_thread()
381+
.build()
382+
.unwrap();
383+
384+
rt.block_on(async move {
385+
let parts = axum_core::extract::Request::builder()
386+
.method("GET")
387+
.uri("/")
388+
.body(())
389+
.unwrap()
390+
.into_parts()
391+
.0;
392+
393+
let ctx = FullstackContext::new(parts);
394+
let err = ctx
395+
.clone()
396+
.scope(async move { FullstackContext::extract::<RedirectingExtractor, ()>().await })
397+
.await
398+
.unwrap_err();
399+
400+
match err {
401+
ServerFnError::Redirect { code, location, .. } => {
402+
assert_eq!(code, StatusCode::TEMPORARY_REDIRECT.as_u16());
403+
assert_eq!(location, "/sign-in");
404+
}
405+
other => panic!("expected ServerFnError::Redirect, got {other:?}"),
406+
}
407+
});
408+
}
409+
}

packages/fullstack-server/src/server.rs

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -374,15 +374,29 @@ impl FullstackState {
374374
.into_response()
375375
}
376376

377-
Err(SSRError::HttpError { status, message }) => Response::builder()
378-
.status(status)
379-
.body(Body::from(message.unwrap_or_else(|| {
380-
status
381-
.canonical_reason()
382-
.unwrap_or("An unknown error occurred")
383-
.to_string()
384-
})))
385-
.unwrap(),
377+
Err(SSRError::HttpError {
378+
status,
379+
message,
380+
headers,
381+
}) => {
382+
let mut response = Response::builder()
383+
.status(status)
384+
.body(Body::from(message.unwrap_or_else(|| {
385+
status
386+
.canonical_reason()
387+
.unwrap_or("An unknown error occurred")
388+
.to_string()
389+
})))
390+
.unwrap();
391+
392+
for (key, value) in headers.into_iter() {
393+
if let Some(key) = key {
394+
response.headers_mut().insert(key, value);
395+
}
396+
}
397+
398+
response
399+
}
386400
}
387401
}
388402
}

packages/fullstack-server/src/ssr.rs

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ use dioxus_router::ParseRouteError;
1717
use dioxus_ssr::Renderer;
1818
use futures_channel::mpsc::Sender;
1919
use futures_util::{Stream, StreamExt};
20-
use http::{request::Parts, HeaderMap, StatusCode};
20+
use http::{header::LOCATION, request::Parts, HeaderMap, HeaderValue, StatusCode};
2121
use std::{
2222
collections::HashMap,
2323
fmt::Write,
@@ -37,6 +37,7 @@ pub enum SSRError {
3737
HttpError {
3838
status: StatusCode,
3939
message: Option<String>,
40+
headers: HeaderMap,
4041
},
4142
}
4243

@@ -140,6 +141,7 @@ impl SsrRendererPool {
140141
.ok_or_else(|| SSRError::HttpError {
141142
status: StatusCode::BAD_REQUEST,
142143
message: None,
144+
headers: HeaderMap::new(),
143145
})?
144146
.to_string();
145147

@@ -244,6 +246,7 @@ impl SsrRendererPool {
244246
if let Some(error) = error {
245247
let mut status_code = None;
246248
let mut out_message = None;
249+
let mut out_headers = HeaderMap::new();
247250

248251
// If the errors include an `HttpError` or `StatusCode` or `ServerFnError`, we need
249252
// to try and return the appropriate status code
@@ -258,15 +261,30 @@ impl SsrRendererPool {
258261

259262
// todo - the user is allowed to return anything that impls `From<ServerFnError>`
260263
// we need to eventually be able to downcast that and get the status code from it
261-
if let Some(ServerFnError::ServerError { message, code, .. }) = error.downcast_ref()
262-
{
263-
status_code = Some(
264-
(*code)
265-
.try_into()
266-
.unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
267-
);
268-
269-
out_message = Some(message.clone());
264+
if let Some(server_fn_error) = error.downcast_ref::<ServerFnError>() {
265+
match server_fn_error {
266+
ServerFnError::ServerError { message, code, .. } => {
267+
status_code = Some(
268+
(*code)
269+
.try_into()
270+
.unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
271+
);
272+
out_message = Some(message.clone());
273+
}
274+
ServerFnError::Redirect { code, location, .. } => {
275+
status_code = Some(
276+
(*code)
277+
.try_into()
278+
.unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
279+
);
280+
// Redirects are control-flow; no message body by default.
281+
out_message = None;
282+
if let Ok(value) = HeaderValue::from_str(location) {
283+
out_headers.insert(LOCATION, value);
284+
}
285+
}
286+
_ => {}
287+
}
270288
}
271289

272290
// If there was an error while routing, return the error with a 404 status
@@ -281,6 +299,7 @@ impl SsrRendererPool {
281299
_ = initial_result_tx.send(Err(SSRError::HttpError {
282300
status: status_code,
283301
message: out_message,
302+
headers: out_headers,
284303
}));
285304
return;
286305
}

packages/fullstack/src/magic.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -428,6 +428,11 @@ mod decode_ok {
428428
.unwrap_or(StatusCode::INTERNAL_SERVER_ERROR))
429429
}
430430

431+
ServerFnError::Redirect { code, .. } => {
432+
Err(StatusCode::from_u16(code)
433+
.unwrap_or(StatusCode::INTERNAL_SERVER_ERROR))
434+
}
435+
431436
ServerFnError::Registration(_) | ServerFnError::MiddlewareError(_) => {
432437
Err(StatusCode::INTERNAL_SERVER_ERROR)
433438
}

0 commit comments

Comments
 (0)