generalize error redirect behavior across integrations

This commit is contained in:
Greg Johnston 2024-01-13 21:58:46 -05:00
parent 88fee243a8
commit 1ad7ee8a03
14 changed files with 159 additions and 157 deletions

View file

@ -22,7 +22,6 @@ parking_lot = "0.12.1"
regex = "1.7.0"
tracing = "0.1.37"
tokio = { version = "1", features = ["rt", "fs"] }
url = "2.5.0"
[features]
nonce = ["leptos/nonce"]

View file

@ -6,10 +6,7 @@
//! [`examples`](https://github.com/leptos-rs/leptos/tree/main/examples)
//! directory in the Leptos repository.
use actix_http::{
body::MessageBody,
header::{HeaderName, HeaderValue, ACCEPT, LOCATION, REFERER},
};
use actix_http::header::{HeaderName, HeaderValue, ACCEPT};
use actix_web::{
body::BoxBody,
dev::{ServiceFactory, ServiceRequest},
@ -28,24 +25,15 @@ use leptos_meta::*;
use leptos_router::*;
use parking_lot::RwLock;
use regex::Regex;
use server_fn::{
error::{
NoCustomError, ServerFnErrorSerde, ServerFnUrlError,
SERVER_FN_ERROR_HEADER,
},
redirect::REDIRECT_HEADER,
request::actix::ActixRequest,
};
use server_fn::{redirect::REDIRECT_HEADER, request::actix::ActixRequest};
use std::{
fmt::{Debug, Display},
future::Future,
pin::Pin,
str::FromStr,
sync::Arc,
};
#[cfg(debug_assertions)]
use tracing::instrument;
use url::Url;
/// This struct lets you define headers and override the status of the Response from an Element or a Server Function
/// Typically contained inside of a ResponseOptions. Setting this is useful for cookies and custom responses.
#[derive(Debug, Clone, Default)]
@ -253,82 +241,12 @@ pub fn handle_server_fns_with_context(
let res_parts = ResponseOptions::default();
provide_context(res_parts.clone());
let accepts_html = req
.headers()
.get(ACCEPT)
.and_then(|v| v.to_str().ok())
.map(|v| v.contains("text/html"))
.unwrap_or(false);
let referrer = req.headers().get(REFERER).cloned();
let mut res = service
.0
.run(ActixRequest::from((req, payload)))
.await
.take();
// it it accepts text/html (i.e., is a plain form post) and doesn't already have a
// Location set, then redirect to to Referer
if accepts_html {
if let Some(mut referrer) = referrer {
let location = res.headers().get(LOCATION);
if location.is_none() {
let is_error = res.status()
== StatusCode::INTERNAL_SERVER_ERROR;
if is_error {
if let Some(Ok(path)) = res
.headers()
.get(SERVER_FN_ERROR_HEADER)
.map(|n| n.to_str().map(|n| n.to_owned()))
{
let (headers, body) = res.into_parts();
if let Ok(body) = body.try_into_bytes() {
if let Ok(body) =
String::from_utf8(body.to_vec())
{
// TODO allow other kinds?
let err: ServerFnError<
NoCustomError,
> = ServerFnErrorSerde::de(&body);
if let Ok(referrer_str) =
referrer.to_str()
{
let mut modified =
Url::parse(referrer_str)
.expect(
"couldn't parse \
URL from Referer \
header.",
);
modified
.query_pairs_mut()
.append_pair(
"__path",
&path
)
.append_pair(
"__err",
&ServerFnErrorSerde::ser(&err).unwrap_or_default()
);
let modified =
HeaderValue::from_str(
modified.as_ref(),
);
if let Ok(header) = modified {
referrer = header;
}
}
}
}
res = headers.set_body(BoxBody::new(""))
}
};
*res.status_mut() = StatusCode::FOUND;
res.headers_mut().insert(LOCATION, referrer);
}
}
};
// Override StatusCode if it was set in a Resource or Element
if let Some(status) = res_parts.0.read().status {
*res.status_mut() = status;

View file

@ -20,7 +20,7 @@ typed-builder = "0.18"
typed-builder-macro = "0.18"
serde = { version = "1", optional = true }
serde_json = { version = "1", optional = true }
server_fn = { workspace = true, features = ["browser"] }
server_fn = { workspace = true, features = ["browser", "url", "cbor"] }
web-sys = { version = "0.3.63", features = [
"ShadowRoot",
"ShadowRootInit",

View file

@ -3,10 +3,7 @@ use leptos_reactive::{
batch, create_rw_signal, is_suppressing_resource_load, signal_prelude::*,
spawn_local, store_value, use_context, ReadSignal, RwSignal, StoredValue,
};
use server_fn::{
error::{NoCustomError, ServerFnUrlError},
ServerFn, ServerFnError,
};
use server_fn::{error::ServerFnUrlError, ServerFn, ServerFnError};
use std::{cell::Cell, future::Future, pin::Pin, rc::Rc};
/// An action synchronizes an imperative `async` call to the synchronous reactive system.

View file

@ -64,9 +64,10 @@ reqwest = { version = "0.11", default-features = false, optional = true, feature
"multipart",
"stream",
] }
url = "2"
[features]
default = ["url", "json", "cbor"]
default = [ "json", "cbor"]
actix = ["ssr", "dep:actix-web", "dep:send_wrapper"]
axum = [
"ssr",

View file

@ -7,6 +7,7 @@ use std::{
sync::Arc,
};
use thiserror::Error;
use url::Url;
/// A custom header that can be used to indicate a server function returned an error.
pub const SERVER_FN_ERROR_HEADER: &'static str = "serverfnerror";
@ -403,46 +404,21 @@ impl<CustErr> From<ServerFnError<CustErr>> for ServerFnErrorErr<CustErr> {
}
}
/// TODO: Write Documentation
/// Associates a particular server function error with the server function
/// found at a particular path.
///
/// This can be used to pass an error from the server back to the client
/// without JavaScript/WASM supported, by encoding it in the URL as a qurey string.
/// This is useful for progressive enhancement.
#[derive(Debug)]
pub struct ServerFnUrlError<CustErr> {
path: String,
error: ServerFnError<CustErr>,
}
impl<CustErr> FromStr for ServerFnUrlError<CustErr>
where
CustErr: FromStr + Display,
{
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.split_once('|') {
None => Err(()),
Some((path, error)) => {
let error = ServerFnError::<CustErr>::de(error);
Ok(ServerFnUrlError {
path: path.to_string(),
error,
})
}
}
}
}
impl<CustErr> Display for ServerFnUrlError<CustErr>
where
CustErr: FromStr + Display,
{
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}|", self.path)?;
write!(f, "{}", &self.error.ser()?)?;
Ok(())
}
}
impl<CustErr> ServerFnUrlError<CustErr> {
/// TODO: Write Documentation
/// Creates a new structure associating the server function at some path
/// with a particular error.
pub fn new(path: impl Display, error: ServerFnError<CustErr>) -> Self {
Self {
path: path.to_string(),
@ -450,15 +426,30 @@ impl<CustErr> ServerFnUrlError<CustErr> {
}
}
/// TODO: Write documentation
/// The error itself.
pub fn error(&self) -> &ServerFnError<CustErr> {
&self.error
}
/// TODO: Add docs
/// The path of the server function that generated this error.
pub fn path(&self) -> &str {
&self.path
}
/// Adds an encoded form of this server function error to the given base URL.
pub fn to_url(&self, base: &str) -> Result<Url, url::ParseError>
where
CustErr: FromStr + Display,
{
let mut url = Url::parse(base)?;
url.query_pairs_mut()
.append_pair("__path", &self.path)
.append_pair(
"__err",
&ServerFnErrorSerde::ser(&self.error).unwrap_or_default(),
);
Ok(url)
}
}
impl<CustErr> From<ServerFnUrlError<CustErr>> for ServerFnError<CustErr> {

View file

@ -1,5 +1,4 @@
#![forbid(unsafe_code)]
// uncomment this if you want to feel pain
#![deny(missing_docs)]
//! # Server Functions
@ -126,7 +125,7 @@ use codec::{Encoding, FromReq, FromRes, IntoReq, IntoRes};
pub use const_format;
use dashmap::DashMap;
pub use error::ServerFnError;
use error::ServerFnErrorSerde;
use error::{ServerFnErrorSerde, ServerFnUrlError};
use http::Method;
use middleware::{Layer, Service};
use once_cell::sync::Lazy;
@ -236,10 +235,42 @@ where
fn run_on_server(
req: Self::ServerRequest,
) -> impl Future<Output = Self::ServerResponse> + Send {
async {
Self::execute_on_server(req).await.unwrap_or_else(|e| {
Self::ServerResponse::error_response(Self::PATH, e)
})
// Server functions can either be called by a real Client,
// or directly by an HTML <form>. If they're accessed by a <form>, default to
// redirecting back to the Referer.
let accepts_html = req
.accepts()
.map(|n| n.contains("text/html"))
.unwrap_or(false);
let mut referer = req.referer().as_deref().map(ToOwned::to_owned);
async move {
let (mut res, err) = Self::execute_on_server(req)
.await
.map(|res| (res, None))
.unwrap_or_else(|e| {
(
Self::ServerResponse::error_response(Self::PATH, &e),
Some(e),
)
});
// if it accepts HTML, we'll redirect to the Referer
if accepts_html {
// if it had an error, encode that error in the URL
if let Some(err) = err {
if let Ok(url) = ServerFnUrlError::new(Self::PATH, err)
.to_url(referer.as_deref().unwrap_or("/"))
{
referer = Some(url.to_string());
}
}
// set the status code and Location header
res.redirect(referer.as_deref().unwrap_or("/"));
}
res
}
}

View file

@ -53,7 +53,7 @@ mod axum {
Box::pin(async move {
inner.await.unwrap_or_else(|e| {
let err = ServerFnError::from(e);
Response::<Body>::error_response(&path, err)
Response::<Body>::error_response(&path, &err)
})
})
}
@ -131,7 +131,7 @@ mod actix {
Box::pin(async move {
inner.await.unwrap_or_else(|e| {
let err = ServerFnError::new(e);
ActixResponse::error_response(&path, err).take()
ActixResponse::error_response(&path, &err).take()
})
})
}
@ -152,7 +152,7 @@ mod actix {
Box::pin(async move {
ActixResponse::from(inner.await.unwrap_or_else(|e| {
let err = ServerFnError::new(e);
ActixResponse::error_response(&path, err).take()
ActixResponse::error_response(&path, &err).take()
}))
})
}

View file

@ -3,7 +3,7 @@ use actix_web::{web::Payload, HttpRequest};
use bytes::Bytes;
use futures::Stream;
use send_wrapper::SendWrapper;
use std::future::Future;
use std::{borrow::Cow, future::Future};
/// A wrapped Actix request.
///
@ -17,6 +17,14 @@ impl ActixRequest {
pub fn take(self) -> (HttpRequest, Payload) {
self.0.take()
}
fn header(&self, name: &str) -> Option<Cow<'_, str>> {
self.0
.0
.headers()
.get(name)
.map(|h| String::from_utf8_lossy(h.as_bytes()))
}
}
impl From<(HttpRequest, Payload)> for ActixRequest {
@ -30,12 +38,16 @@ impl<CustErr> Req<CustErr> for ActixRequest {
self.0 .0.uri().query()
}
fn to_content_type(&self) -> Option<String> {
self.0
.0
.headers()
.get("Content-Type")
.map(|h| String::from_utf8_lossy(h.as_bytes()).to_string())
fn to_content_type(&self) -> Option<Cow<'_, str>> {
self.header("Content-Type")
}
fn accepts(&self) -> Option<Cow<'_, str>> {
self.header("Accept")
}
fn referer(&self) -> Option<Cow<'_, str>> {
self.header("Referer")
}
fn try_into_bytes(

View file

@ -1,18 +1,34 @@
use crate::{error::ServerFnError, request::Req};
use axum::body::{Body, Bytes};
use futures::{Stream, StreamExt};
use http::{header::CONTENT_TYPE, Request};
use http::{
header::{ACCEPT, CONTENT_TYPE, REFERER},
Request,
};
use http_body_util::BodyExt;
use std::borrow::Cow;
impl<CustErr> Req<CustErr> for Request<Body> {
fn as_query(&self) -> Option<&str> {
self.uri().query()
}
fn to_content_type(&self) -> Option<String> {
fn to_content_type(&self) -> Option<Cow<'_, str>> {
self.headers()
.get(CONTENT_TYPE)
.map(|h| String::from_utf8_lossy(h.as_bytes()).to_string())
.map(|h| String::from_utf8_lossy(h.as_bytes()))
}
fn accepts(&self) -> Option<Cow<'_, str>> {
self.headers()
.get(ACCEPT)
.map(|h| String::from_utf8_lossy(h.as_bytes()))
}
fn referer(&self) -> Option<Cow<'_, str>> {
self.headers()
.get(REFERER)
.map(|h| String::from_utf8_lossy(h.as_bytes()))
}
async fn try_into_bytes(self) -> Result<Bytes, ServerFnError<CustErr>> {

View file

@ -1,7 +1,7 @@
use crate::error::ServerFnError;
use bytes::Bytes;
use futures::Stream;
use std::future::Future;
use std::{borrow::Cow, future::Future};
/// Request types for Actix.
#[cfg(any(feature = "actix", doc))]
@ -73,7 +73,13 @@ where
fn as_query(&self) -> Option<&str>;
/// Returns the `Content-Type` header, if any.
fn to_content_type(&self) -> Option<String>;
fn to_content_type(&self) -> Option<Cow<'_, str>>;
/// Returns the `Accepts` header, if any.
fn accepts(&self) -> Option<Cow<'_, str>>;
/// Returns the `Referer` header, if any.
fn referer(&self) -> Option<Cow<'_, str>>;
/// Attempts to extract the body of the request into [`Bytes`].
fn try_into_bytes(
@ -103,10 +109,17 @@ impl<CustErr> Req<CustErr> for BrowserMockReq {
unreachable!()
}
fn to_content_type(&self) -> Option<String> {
fn to_content_type(&self) -> Option<Cow<'_, str>> {
unreachable!()
}
fn accepts(&self) -> Option<Cow<'_, str>> {
unreachable!()
}
fn referer(&self) -> Option<Cow<'_, str>> {
unreachable!()
}
async fn try_into_bytes(self) -> Result<Bytes, ServerFnError<CustErr>> {
unreachable!()
}

View file

@ -1,10 +1,13 @@
use super::Res;
use crate::error::{
ServerFnError, ServerFnErrorErr, ServerFnErrorSerde, ServerFnUrlError,
SERVER_FN_ERROR_HEADER,
ServerFnError, ServerFnErrorErr, ServerFnErrorSerde, SERVER_FN_ERROR_HEADER,
};
use actix_web::{
http::{header, StatusCode},
http::{
header,
header::{HeaderValue, LOCATION},
StatusCode,
},
HttpResponse,
};
use bytes::Bytes;
@ -77,11 +80,18 @@ where
)))
}
fn error_response(path: &str, err: ServerFnError<CustErr>) -> Self {
fn error_response(path: &str, err: &ServerFnError<CustErr>) -> Self {
ActixResponse(SendWrapper::new(
HttpResponse::build(StatusCode::INTERNAL_SERVER_ERROR)
.append_header((SERVER_FN_ERROR_HEADER, path))
.body(err.ser().unwrap_or_else(|_| err.to_string())),
))
}
fn redirect(&mut self, path: &str) {
if let Ok(path) = HeaderValue::from_str(path) {
*self.0.status_mut() = StatusCode::FOUND;
self.0.headers_mut().insert(LOCATION, path);
}
}
}

View file

@ -3,7 +3,7 @@ use crate::error::{ServerFnError, ServerFnErrorErr};
use axum::body::Body;
use bytes::Bytes;
use futures::{Stream, StreamExt};
use http::Response;
use http::{header, HeaderValue, Response, StatusCode};
use std::fmt::{Debug, Display};
impl<CustErr> Res<CustErr> for Response<Body>
@ -50,10 +50,17 @@ where
.map_err(|e| ServerFnError::Response(e.to_string()))
}
fn error_response(path: &str, err: ServerFnError<CustErr>) -> Self {
fn error_response(path: &str, err: &ServerFnError<CustErr>) -> Self {
Response::builder()
.status(http::StatusCode::INTERNAL_SERVER_ERROR)
.body(Body::from(err.to_string()))
.unwrap()
}
fn redirect(&mut self, path: &str) {
if let Ok(path) = HeaderValue::from_str(path) {
self.headers_mut().insert(header::LOCATION, path);
*self.status_mut() = StatusCode::FOUND;
}
}
}

View file

@ -42,7 +42,10 @@ where
) -> Result<Self, ServerFnError<CustErr>>;
/// Converts an error into a response, with a `500` status code and the error text as its body.
fn error_response(path: &str, err: ServerFnError<CustErr>) -> Self;
fn error_response(path: &str, err: &ServerFnError<CustErr>) -> Self;
/// Redirect the response by setting a 302 code and Location header.
fn redirect(&mut self, path: &str);
}
/// Represents the response as received by the client.
@ -101,7 +104,7 @@ impl<CustErr> Res<CustErr> for BrowserMockRes {
unreachable!()
}
fn error_response(_path: &str, _err: ServerFnError<CustErr>) -> Self {
fn error_response(_path: &str, _err: &ServerFnError<CustErr>) -> Self {
unreachable!()
}
@ -109,6 +112,10 @@ impl<CustErr> Res<CustErr> for BrowserMockRes {
_content_type: &str,
_data: impl Stream<Item = Result<Bytes, ServerFnError<CustErr>>>,
) -> Result<Self, ServerFnError<CustErr>> {
todo!()
unreachable!()
}
fn redirect(&mut self, _path: &str) {
unreachable!()
}
}