From 8f6e7dc1f216e844301f2043232bafe7086a11b1 Mon Sep 17 00:00:00 2001 From: Jonathan Kelley Date: Mon, 13 Oct 2025 16:02:33 -0700 Subject: [PATCH] fix: server function custom errors set status codes (#4782) * fix: handling status codes from errors * add demo * clippy + check * fix clippy again --- Cargo.toml | 5 + examples/07-fullstack/handling_errors.rs | 24 +- examples/07-fullstack/server_functions.rs | 271 ++++++++++++++++++++++ packages/fullstack-core/src/error.rs | 27 +-- packages/fullstack-core/src/streaming.rs | 6 +- packages/fullstack-server/src/ssr.rs | 15 +- packages/fullstack/src/lib.rs | 2 + packages/fullstack/src/magic.rs | 46 ++-- packages/native/src/link_handler.rs | 4 +- 9 files changed, 356 insertions(+), 44 deletions(-) create mode 100644 examples/07-fullstack/server_functions.rs diff --git a/Cargo.toml b/Cargo.toml index b7ac046ce..cdb13aef9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -699,6 +699,11 @@ name = "flat_router" path = "examples/06-routing/flat_router.rs" doc-scrape-examples = true +[[example]] +name = "server_functions" +path = "examples/07-fullstack/server_functions.rs" +doc-scrape-examples = true + [[example]] name = "middleware" path = "examples/07-fullstack/middleware.rs" diff --git a/examples/07-fullstack/handling_errors.rs b/examples/07-fullstack/handling_errors.rs index 3b06d8095..b1bc6431f 100644 --- a/examples/07-fullstack/handling_errors.rs +++ b/examples/07-fullstack/handling_errors.rs @@ -22,7 +22,7 @@ //! //! Try running requests against the endpoints directly with `curl` or `postman` to see the actual HTTP responses! -use dioxus::fullstack::{Json, StatusCode}; +use dioxus::fullstack::{AsStatusCode, Json, StatusCode}; use dioxus::prelude::*; use serde::{Deserialize, Serialize}; @@ -164,10 +164,32 @@ enum MyCustomError { ServerFnError(#[from] ServerFnError), } +impl AsStatusCode for MyCustomError { + fn as_status_code(&self) -> StatusCode { + match self { + MyCustomError::BadRequest { .. } => StatusCode::BAD_REQUEST, + MyCustomError::NotFound => StatusCode::NOT_FOUND, + MyCustomError::ServerFnError(e) => e.as_status_code(), + } + } +} + /// Returns a custom typed error (serializable) so clients can handle specific cases. +/// +/// Our custom error must implement `AsStatusCode` so it can properly set the outgoing HTTP status code. #[get("/api/typed-error")] async fn get_throws_typed_error() -> Result<(), MyCustomError> { Err(MyCustomError::BadRequest { custom_name: "Invalid input".into(), }) } + +/// Simple POST endpoint used to show a successful server function that returns `StatusCode`. +#[post("/api/data")] +async fn get_throws_serverfn_error() -> Result<(), ServerFnError> { + Err(ServerFnError::ServerError { + message: "Unauthorized access".to_string(), + code: StatusCode::UNAUTHORIZED.as_u16(), + details: None, + }) +} diff --git a/examples/07-fullstack/server_functions.rs b/examples/07-fullstack/server_functions.rs new file mode 100644 index 000000000..4a1f77eb1 --- /dev/null +++ b/examples/07-fullstack/server_functions.rs @@ -0,0 +1,271 @@ +//! This example is a simple showcase of Dioxus Server Functions. +//! +//! The other examples in this folder showcase advanced features of server functions like custom +//! data types, error handling, websockets, and more. +//! +//! This example is meant to just be a simple starting point to show how server functions work. +//! +//! ## Server Functions +//! +//! In Dioxus, Server Functions are `axum` backend endpoints that can be called directly from the client +//! as if you were simply calling a local Rust function. You can do anything with a server function +//! that an Axum handler can do like extracting path, query, headers, and body parameters. +//! +//! ## Server Function Arguments +//! +//! Unlike Axum handlers, the arguments of the server functions have some special magic enabled by +//! the accompanying `#[get]`/`#[post]` attributes. This magic enables you to choose between +//! arguments that are purely serializable (i.e. `String`, `i32`, `Vec`, etc) as the JSON body of +//! +//! the request *or* arguments that implement Axum's `FromRequest` trait. This magic enables simple +//! RPC functions but also complex extractors for things like auth, sessions, cookies, and more. +//! +//! ## Server Function Return Types +//! +//! The return type of the server function is also somewhat magical. Unlike Axum handlers, all server +//! functions must return a `Result` type, giving the client an opportunity to handle errors properly. +//! +//! The `Ok` type can be anything that implements `Serialize + DeserializeOwned` so it can be sent +//! to the client as JSON, or it can be anything that implements `IntoResponse` just like an Axum handler. +//! +//! ## Error Types +//! +//! The `Err` type of the server function return type is also somewhat special. The `Err` type can be: +//! - `anyhow::Error` (the `dioxus_core::Err` type alias) for untyped errors with rich context. Note +//! that these errors will always downcast to `ServerFnError` on the client, losing the original +//! error stack and type. +//! - `ServerFnError` for typed errors with a status code and optional message. +//! - `StatusCode` for returning raw HTTP status codes. +//! - `HttpError` for returning HTTP status codes with custom messages. +//! - Any custom errors that implement `From` and are `Serialize`/`Deserialize` +//! +//! The only way to set the HTTP status code of the response is to use one of the above error types, +//! or to implement a custom `IntoResponse` type that sets the status code manually. +//! +//! The `anyhow::Error` type is the best choice for rapid development, but is somewhat limited when +//! handling specific error cases on the client since all errors are downcast to `ServerFnError`. +//! +//! ## Calling Server Functions from the Client +//! +//! Server functions can be called from the client by simply importing the function and calling it +//! like a normal Rust function. Unlike regular axum handlers, Dioxus server functions have a few +//! non-obvious restrictions. +//! +//! Most importantly, the arguments to the server function must implement either `Deserialize` *or* +//! `IntoRequest`. The `IntoRequest` trait is a Dioxus abstraction that represents the "inverse" of the +//! Axum `FromRequest` trait. Anything that is sent to the server from the client must be both extractable +//! with `FromRequest` on the server *and* constructible with `IntoRequest` on the client. +//! +//! Types like `WebsocketOptions` implement `IntoRequest` and pass along things like upgrade headers +//! to the server so that the server can properly upgrade the connection. +//! +//! When receiving data from the server, the return type must implement `Deserialize` *or* `FromResponse`. +//! The `FromResponse` trait is the inverse of Axum's `IntoResponse` trait, and is implemented +//! for types like `Websocket` where the raw HTTP response is needed to complete the construction +//! of the type. +//! +//! ## Server-only Extractors +//! +//! Because the arguments of the server function define the structure of the public API, some extractors +//! might not make sense to expose directly, nor would they be possible to construct on the client. +//! For example, on the web, you typically don't work directly with cookies since the browser handles +//! them for you. In these cases, the client would omit the `Cookie` header entirely, and we would need +//! "hoist" our extractor into a "server-only extractor". +//! +//! Server-only extractors are function arguments placed after the path in the `#[get]`/`#[post]` attribute. +//! These arguments are extracted on the server, but not passed in from the client. This lets the +//! server function remain callable from the client, while still allowing full access to axum's +//! extractors. +//! +//! ``` +//! #[post("/api/authenticate", auth: AuthCookie)] +//! async fn authenticate() -> Result { /* ... */ } +//! ``` +//! +//! ## Automatic Registration +//! +//! Unlike axum handlers, server functions do not need to be manually registered with a router. +//! By default, *all* server functions in your app will be automatically registered with the +//! server when you call `dioxus::launch` or create a router manually with `dioxus::server::router()`. +//! +//! However, not all server functions are automatically registered by default. Server functions that +//! take a `State` extractor cannot be automatically added to the router since the dioxus router +//! type does not know how to construct the `T` type. +//! +//! These server functions will be registered once the `ServerFnState` layer is added to the app with +//! `router = router.layer(ServerFnState::new(your_state))`. +//! +//! ## Middleware +//! +//! Middleware can be added to server functions using the `#[middleware(MiddlewareType)]` attribute. +//! Middleware will be applied in the order they are specified, and will be applied before any +//! server-only extractors. +//! +//! To add router-level middleware, you can customize the axum `Router` using layers and extensions +//! as you would in a normal axum app. +//! +//! ## Anonymous Server Functions +//! +//! The `#[server]` attribute can be used without a path to create an anonymous server function. +//! These functions are still exposed as HTTP endpoints, but their names are procedurally generated +//! from the module path, function name, and a hash of the function signature. This makes it hard to +//! call these functions with `curl` or `postman`, but save you the trouble of coming up with unique +//! names for simple functions that are only called from your Dioxus app. +//! +//! If you're shipping desktop/mobile apps, we don't recommend using anonymous server functions +//! since the function names could change between builds and thus make older versions of your app +//! incompatible with newer versions of your server. +//! +//! ## Cross-platform Clients +//! +//! Server functions can be called from any platform (web, desktop mobile, etc) and use the best +//! underlying `fetch` implementation available. +//! +//! ## More examples +//! +//! With Dioxus Fullstack 0.7, pretty much anything you can do with an Axum handler, you can do with +//! a server function. More advanced examples can be found in this folder showcasing custom data types, +//! error handling, websockets, and more. + +use axum_core::response::IntoResponse; +use dioxus::prelude::*; +use dioxus_fullstack::FromResponse; +use dioxus_fullstack::http::StatusCode; +use serde::{Deserialize, Serialize}; + +fn main() { + dioxus::launch(app); +} + +fn app() -> Element { + let mut echo_action = use_action(echo); + let mut chat_action = use_action(chat); + let mut dog_data = use_action(get_data); + let mut custom_data = use_action(get_custom_data); + let mut anonymous_action = use_action(anonymous); + let mut custom_anonymous_action = use_action(custom_anonymous); + + rsx! { + h1 { "Server Functions Example" } + div { + display: "flex", + flex_direction: "column", + gap: "8px", + + button { onclick: move |_| echo_action.call("Hello from client".into()), "Echo: Hello" } + button { onclick: move |_| chat_action.call(42u32, Some(7u32)), "Chat (user 42, room 7)" } + button { onclick: move |_| dog_data.call(), "Get dog data" } + button { onclick: move |_| custom_data.call(), "Get custom data" } + button { onclick: move |_| anonymous_action.call(), "Call anonymous" } + button { onclick: move |_| custom_anonymous_action.call(), "Call custom anonymous" } + + button { + onclick: move |_| { + echo_action.reset(); + chat_action.reset(); + dog_data.reset(); + custom_data.reset(); + anonymous_action.reset(); + custom_anonymous_action.reset(); + }, + "Clear results" + } + + pre { "Echo result: {echo_action.result():#?}" } + pre { "Chat result: {chat_action.result():#?}" } + pre { "Dog data: {dog_data.result():#?}" } + pre { "Custom data: {custom_data.result():#?}" } + pre { "Anonymous: {anonymous_action.result():#?}" } + pre { "Custom anonymous: {custom_anonymous_action.result():#?}" } + } + } +} + +/// A plain server function at a `POST` endpoint that takes a string and returns it. +/// Here, we use the `Result` return type which is an alias to `Result`. +#[post("/api/echo")] +async fn echo(body: String) -> Result { + Ok(body) +} + +/// A Server function that takes path and query parameters, as well as a server-only extractor. +#[post("/api/{user_id}/chat?room_id", headers: dioxus_fullstack::HeaderMap)] +async fn chat(user_id: u32, room_id: Option) -> Result { + Ok(format!( + "User ID: {}, Room ID: {} - Headers: {:#?}", + user_id, + room_id.map_or("None".to_string(), |id| id.to_string()), + headers + )) +} + +/// A plain server function at a `GET` endpoint that returns some JSON data. Because `DogData` is +/// `Serialize` and `Deserialize`, it can be sent to the client as JSON automatically. +/// +/// You can `curl` this endpoint and it will return a 200 status code with a JSON body: +/// +/// ```json +/// { +/// "name": "Fido", +/// "age": 4 +/// } +/// ``` +#[get("/api/dog")] +async fn get_data() -> Result { + Ok(DogData { + name: "Fido".to_string(), + age: 4, + }) +} + +#[derive(Serialize, Deserialize, Debug)] +struct DogData { + name: String, + age: u8, +} + +/// A server function that returns a custom struct as JSON. +#[get("/api/custom")] +async fn get_custom_data() -> Result { + Ok(CustomData { + message: "Hello from the server!".to_string(), + }) +} + +#[derive(Debug)] +struct CustomData { + message: String, +} +impl IntoResponse for CustomData { + fn into_response(self) -> axum_core::response::Response { + axum_core::response::Response::builder() + .status(StatusCode::ACCEPTED) + .body(serde_json::to_string(&self.message).unwrap().into()) + .unwrap() + } +} + +impl FromResponse for CustomData { + async fn from_response(res: dioxus_fullstack::ClientResponse) -> Result { + let message = res.json::().await?; + Ok(CustomData { message }) + } +} + +/// An anonymous server function - the url path is generated from the module path and function name. +/// +/// This will end up as `/api/anonymous_` where `` is a hash of the function signature. +#[server] +async fn anonymous() -> Result { + Ok("Hello from an anonymous server function!".to_string()) +} + +/// An anonymous server function with a custom prefix and a fixed endpoint name. +/// +/// This is less preferred over the `#[get]`/`#[post]` syntax but is still functional for backwards +/// compatibility. Previously, only the `#[server]` attribute was available, but as of Dioxus 0.7, +/// the `#[get]`/`#[post]` attributes are preferred for new code. +#[server(prefix = "/api/custom", endpoint = "my_anonymous")] +async fn custom_anonymous() -> Result { + Ok("Hello from a custom anonymous server function!".to_string()) +} diff --git a/packages/fullstack-core/src/error.rs b/packages/fullstack-core/src/error.rs index 5781eaf6b..ae4499f65 100644 --- a/packages/fullstack-core/src/error.rs +++ b/packages/fullstack-core/src/error.rs @@ -32,11 +32,11 @@ pub enum ServerFnError { /// representation of the error. #[error("error running server function: {message} (details: {details:#?})")] ServerError { + /// A human-readable message describing the error. message: String, - /// Optional HTTP status code associated with the error. - #[serde(skip_serializing_if = "Option::is_none")] - code: Option, + /// HTTP status code associated with the error. + code: u16, #[serde(skip_serializing_if = "Option::is_none")] details: Option, @@ -89,7 +89,7 @@ impl ServerFnError { ServerFnError::ServerError { message: f.to_string(), details: None, - code: None, + code: 500, } } @@ -110,7 +110,7 @@ impl ServerFnError { ServerFnError::ServerError { message, - code: Some(status.as_u16()), + code: status.as_u16(), details: None, } } @@ -121,7 +121,7 @@ impl From for ServerFnError { ServerFnError::ServerError { message: value.to_string(), details: None, - code: None, + code: 500, } } } @@ -135,11 +135,9 @@ impl From for ServerFnError { impl From for http::StatusCode { fn from(value: ServerFnError) -> Self { match value { - ServerFnError::ServerError { code, .. } => match code { - Some(code) => http::StatusCode::from_u16(code) - .unwrap_or(http::StatusCode::INTERNAL_SERVER_ERROR), - None => http::StatusCode::INTERNAL_SERVER_ERROR, - }, + ServerFnError::ServerError { code, .. } => { + http::StatusCode::from_u16(code).unwrap_or(http::StatusCode::INTERNAL_SERVER_ERROR) + } ServerFnError::Request(err) => match err { RequestError::Status(_, code) => http::StatusCode::from_u16(code) .unwrap_or(http::StatusCode::INTERNAL_SERVER_ERROR), @@ -174,7 +172,7 @@ impl From for ServerFnError { .unwrap_or("Unknown error") .to_string() }), - code: Some(value.status.as_u16()), + code: value.status.as_u16(), details: None, } } @@ -188,9 +186,8 @@ impl IntoResponse for ServerFnError { code, details, } => { - let status = code - .and_then(|c| StatusCode::from_u16(c).ok()) - .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR); + let status = + StatusCode::from_u16(code).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR); let body = if let Some(details) = details { serde_json::json!({ "error": message, diff --git a/packages/fullstack-core/src/streaming.rs b/packages/fullstack-core/src/streaming.rs index 2125ab6ac..e7a6d0f13 100644 --- a/packages/fullstack-core/src/streaming.rs +++ b/packages/fullstack-core/src/streaming.rs @@ -200,9 +200,9 @@ pub fn current_status() -> StreamingStatus { pub fn status_code_from_error(error: &CapturedError) -> StatusCode { if let Some(err) = error.downcast_ref::() { match err { - ServerFnError::ServerError { - code: Some(code), .. - } => return StatusCode::from_u16(*code).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR), + ServerFnError::ServerError { code, .. } => { + return StatusCode::from_u16(*code).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR) + } _ => return StatusCode::INTERNAL_SERVER_ERROR, } } diff --git a/packages/fullstack-server/src/ssr.rs b/packages/fullstack-server/src/ssr.rs index 2f4cef42a..481fb7364 100644 --- a/packages/fullstack-server/src/ssr.rs +++ b/packages/fullstack-server/src/ssr.rs @@ -258,15 +258,12 @@ impl SsrRendererPool { // we need to eventually be able to downcast that and get the status code from it if let Some(ServerFnError::ServerError { message, code, .. }) = error.downcast_ref() { - if let Some(code) = code { - status_code = Some( - (*code) - .try_into() - .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR), - ); - } else { - status_code = Some(StatusCode::INTERNAL_SERVER_ERROR); - } + status_code = Some( + (*code) + .try_into() + .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR), + ); + out_message = Some(message.clone()); } diff --git a/packages/fullstack/src/lib.rs b/packages/fullstack/src/lib.rs index ccc3fd855..f7a6b8ad5 100644 --- a/packages/fullstack/src/lib.rs +++ b/packages/fullstack/src/lib.rs @@ -54,6 +54,8 @@ pub use client::*; pub use axum::extract::Json; pub use axum::response::{NoContent, Redirect}; +pub use crate::request::{FromResponse, FromResponseParts}; + pub use payloads::*; pub mod payloads { use crate::{ClientRequest, ClientResponse, ClientResult, IntoRequest}; diff --git a/packages/fullstack/src/magic.rs b/packages/fullstack/src/magic.rs index bcc187ea6..9ac3e83ec 100644 --- a/packages/fullstack/src/magic.rs +++ b/packages/fullstack/src/magic.rs @@ -89,8 +89,7 @@ pub enum RestEndpointPayload { pub struct ErrorPayload { message: String, - #[serde(skip_serializing_if = "Option::is_none")] - code: Option, + code: u16, #[serde(skip_serializing_if = "Option::is_none")] data: Option, @@ -280,7 +279,7 @@ mod decode_ok { if let Ok(text) = String::from_utf8(as_bytes.to_vec()) { Ok(RestEndpointPayload::Error(ErrorPayload { message: format!("HTTP {}: {}", status.as_u16(), text), - code: Some(status.as_u16()), + code: status.as_u16(), data: None, })) } else { @@ -397,8 +396,10 @@ mod decode_ok { message: _message, details: _details, code, - } => Err(StatusCode::from_u16(code.unwrap_or(500)) - .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR)), + } => { + Err(StatusCode::from_u16(code) + .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR)) + } ServerFnError::Registration(_) | ServerFnError::MiddlewareError(_) => { Err(StatusCode::INTERNAL_SERVER_ERROR) @@ -439,7 +440,7 @@ mod decode_ok { Ok(Ok(res)) => Ok(res), Ok(Err(res)) => match res { ServerFnError::ServerError { message, code, .. } => Err(HttpError { - status: StatusCode::from_u16(code.unwrap_or(500)) + status: StatusCode::from_u16(code) .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR), message: Some(message), }), @@ -617,16 +618,33 @@ mod resp { fn make_axum_error(self, result: Result) -> Result; } + /// Get the status code from the error type if possible. + pub trait AsStatusCode { + fn as_status_code(&self) -> StatusCode; + } + + impl AsStatusCode for ServerFnError { + fn as_status_code(&self) -> StatusCode { + match self { + Self::ServerError { code, .. } => { + StatusCode::from_u16(*code).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR) + } + _ => StatusCode::INTERNAL_SERVER_ERROR, + } + } + } + impl MakeAxumError for &&&ServerFnDecoder> where - E: From + Serialize + DeserializeOwned + Display, + E: AsStatusCode + From + Serialize + DeserializeOwned + Display, { fn make_axum_error(self, result: Result) -> Result { match result { Ok(res) => Ok(res), Err(err) => { + let status_code = err.as_status_code(); let err = ErrorPayload { - code: None, + code: status_code.as_u16(), message: err.to_string(), data: Some(err), }; @@ -636,7 +654,7 @@ mod resp { http::header::CONTENT_TYPE, HeaderValue::from_static("application/json"), ); - *resp.status_mut() = StatusCode::INTERNAL_SERVER_ERROR; + *resp.status_mut() = status_code; Err(resp) } } @@ -665,7 +683,7 @@ mod resp { }, Ok(other) => ErrorPayload { message: other.to_string(), - code: None, + code: 500, data: None, }, Err(err) => match err.downcast::() { @@ -673,11 +691,11 @@ mod resp { message: http_err .message .unwrap_or_else(|| http_err.status.to_string()), - code: Some(http_err.status.as_u16()), + code: http_err.status.as_u16(), data: None, }, Err(err) => ErrorPayload { - code: None, + code: 500, message: err.to_string(), data: None, }, @@ -706,7 +724,7 @@ mod resp { Ok(resp) => Ok(resp), Err(status) => { let body = serde_json::to_string(&ErrorPayload::<()> { - code: Some(status.as_u16()), + code: status.as_u16(), message: status.to_string(), data: None, }) @@ -732,7 +750,7 @@ mod resp { Ok(resp) => Ok(resp), Err(http_err) => { let body = serde_json::to_string(&ErrorPayload::<()> { - code: Some(http_err.status.as_u16()), + code: http_err.status.as_u16(), message: http_err .message .unwrap_or_else(|| http_err.status.to_string()), diff --git a/packages/native/src/link_handler.rs b/packages/native/src/link_handler.rs index 1dde62402..58331d626 100644 --- a/packages/native/src/link_handler.rs +++ b/packages/native/src/link_handler.rs @@ -10,9 +10,9 @@ impl NavigationProvider for DioxusNativeNavigationProvider { if options.method == Method::GET && matches!(options.url.scheme(), "http" | "https" | "mailto") { - if let Err(err) = webbrowser::open(options.url.as_str()) { + if let Err(_err) = webbrowser::open(options.url.as_str()) { #[cfg(feature = "tracing")] - tracing::error!("Failed to open URL: {}", err); + tracing::error!("Failed to open URL: {}", _err); } } }