working on server fn example

This commit is contained in:
Greg Johnston 2024-01-14 16:17:06 -05:00
parent 1ad7ee8a03
commit 13a2691806
29 changed files with 1012 additions and 0 deletions

View File

@ -0,0 +1,89 @@
[package]
name = "server_fns_axum"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
console_log = "1.0"
console_error_panic_hook = "0.1"
futures = "0.3"
http = "1.0"
leptos = { path = "../../leptos", features = ["nightly"] }
leptos_axum = { path = "../../integrations/axum", optional = true }
leptos_meta = { path = "../../meta", features = ["nightly"] }
leptos_router = { path = "../../router", features = ["nightly"] }
server_fn = { path = "../../server_fn", features = ["serde-lite", "rkyv", "multipart"] }
log = "0.4"
simple_logger = "4.0"
serde = { version = "1", features = ["derive"] }
axum = { version = "0.7", optional = true }
tower = { version = "0.4", optional = true }
tower-http = { version = "0.5", features = ["fs"], optional = true }
tokio = { version = "1", features = ["full"], optional = true }
thiserror = "1.0"
wasm-bindgen = "0.2"
[features]
hydrate = ["leptos/hydrate", "leptos_meta/hydrate", "leptos_router/hydrate"]
ssr = [
"dep:axum",
"dep:tower",
"dep:tower-http",
"dep:tokio",
"leptos/ssr",
"leptos_meta/ssr",
"leptos_router/ssr",
"dep:leptos_axum",
]
[package.metadata.cargo-all-features]
denylist = ["axum", "tower", "tower-http", "tokio", "leptos_axum"]
skip_feature_sets = [["csr", "ssr"], ["csr", "hydrate"], ["ssr", "hydrate"]]
[package.metadata.leptos]
# The name used by wasm-bindgen/cargo-leptos for the JS/WASM bundle. Defaults to the crate name
output-name = "server_fns_axum"
# The site root folder is where cargo-leptos generate all output. WARNING: all content of this folder will be erased on a rebuild. Use it in your server setup.
site-root = "target/site"
# The site-root relative folder where all compiled output (JS, WASM and CSS) is written
# Defaults to pkg
site-pkg-dir = "pkg"
# [Optional] The source CSS file. If it ends with .sass or .scss then it will be compiled by dart-sass into CSS. The CSS is optimized by Lightning CSS before being written to <site-root>/<site-pkg>/app.css
style-file = "./style.css"
# [Optional] Files in the asset-dir will be copied to the site-root directory
assets-dir = "public"
# The IP and port (ex: 127.0.0.1:3000) where the server serves the content. Use it in your server setup.
site-addr = "127.0.0.1:3000"
# The port to use for automatic reload monitoring
reload-port = 3001
# [Optional] Command to use when running end2end tests. It will run in the end2end dir.
end2end-cmd = "cargo make test-ui"
end2end-dir = "e2e"
# The browserlist query used for optimizing the CSS.
browserquery = "defaults"
# Set by cargo-leptos watch when building with tha tool. Controls whether autoreload JS will be included in the head
watch = false
# The environment Leptos will run in, usually either "DEV" or "PROD"
env = "DEV"
# The features to use when compiling the bin target
#
# Optional. Can be over-ridden with the command line parameter --bin-features
bin-features = ["ssr"]
# If the --no-default-features flag should be used when compiling the bin target
#
# Optional. Defaults to false.
bin-default-features = false
# The features to use when compiling the lib target
#
# Optional. Can be over-ridden with the command line parameter --lib-features
lib-features = ["hydrate"]
# If the --no-default-features flag should be used when compiling the lib target
#
# Optional. Defaults to false.
lib-default-features = false

View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2022 Greg Johnston
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -0,0 +1,12 @@
extend = [
{ path = "../cargo-make/main.toml" },
{ path = "../cargo-make/cargo-leptos-webdriver-test.toml" },
]
[env]
CLIENT_PROCESS_NAME = "todo_app_sqlite_axum"
[tasks.test-ui]
cwd = "./e2e"
command = "cargo"
args = ["make", "test-ui", "${@}"]

View File

@ -0,0 +1,19 @@
# Leptos Todo App Sqlite with Axum
This example creates a basic todo app with an Axum backend that uses Leptos' server functions to call sqlx from the client and seamlessly run it on the server.
## Getting Started
See the [Examples README](../README.md) for setup and run instructions.
## E2E Testing
See the [E2E README](./e2e/README.md) for more information about the testing strategy.
## Rendering
See the [SSR Notes](../SSR_NOTES.md) for more information about Server Side Rendering.
## Quick Start
Run `cargo leptos watch` to run this example.

Binary file not shown.

View File

@ -0,0 +1,18 @@
[package]
name = "todo_app_sqlite_axum_e2e"
version = "0.1.0"
edition = "2021"
[dev-dependencies]
anyhow = "1.0.72"
async-trait = "0.1.72"
cucumber = "0.19.1"
fantoccini = "0.19.3"
pretty_assertions = "1.4.0"
serde_json = "1.0.104"
tokio = { version = "1.29.1", features = ["macros", "rt-multi-thread", "time"] }
url = "2.4.0"
[[test]]
name = "app_suite"
harness = false # Allow Cucumber to print output instead of libtest

View File

@ -0,0 +1,20 @@
extend = { path = "../../cargo-make/main.toml" }
[tasks.test]
env = { RUN_AUTOMATICALLY = false }
condition = { env_true = ["RUN_AUTOMATICALLY"] }
[tasks.ci]
[tasks.test-ui]
command = "cargo"
args = [
"test",
"--test",
"app_suite",
"--",
"--retry",
"2",
"--fail-fast",
"${@}",
]

View File

@ -0,0 +1,34 @@
# E2E Testing
This example demonstrates e2e testing with Rust using executable requirements.
## Testing Stack
| | Role | Description |
|---|---|---|
| [Cucumber](https://github.com/cucumber-rs/cucumber/tree/main) | Test Runner | Run [Gherkin](https://cucumber.io/docs/gherkin/reference/) specifications as Rust tests |
| [Fantoccini](https://github.com/jonhoo/fantoccini/tree/main) | Browser Client | Interact with web pages through WebDriver |
| [Cargo Leptos ](https://github.com/leptos-rs/cargo-leptos) | Build Tool | Compile example and start the server and end-2-end tests |
| [chromedriver](https://chromedriver.chromium.org/downloads) | WebDriver | Provide WebDriver for Chrome
## Testing Organization
Testing is organized around what a user can do and see/not see. Test scenarios are grouped by the **user action** and the **object** of that action. This makes it easier to locate and reason about requirements.
Here is a brief overview of how things fit together.
```bash
features
└── {action}_{object}.feature # Specify test scenarios
tests
├── fixtures
│ ├── action.rs # Perform a user action (click, type, etc.)
│ ├── check.rs # Assert what a user can see/not see
│ ├── find.rs # Query page elements
│ ├── mod.rs
│ └── world
│ ├── action_steps.rs # Map Gherkin steps to user actions
│ ├── check_steps.rs # Map Gherkin steps to user expectations
│ └── mod.rs
└── app_suite.rs # Test main
```

View File

@ -0,0 +1,16 @@
@add_todo
Feature: Add Todo
Background:
Given I see the app
@add_todo-see
Scenario: Should see the todo
Given I set the todo as Buy Bread
When I click the Add button
Then I see the todo named Buy Bread
@add_todo-style
Scenario: Should see the pending todo
When I add a todo as Buy Oranges
Then I see the pending todo

View File

@ -0,0 +1,18 @@
@delete_todo
Feature: Delete Todo
Background:
Given I see the app
@serial
@delete_todo-remove
Scenario: Should not see the deleted todo
Given I add a todo as Buy Yogurt
When I delete the todo named Buy Yogurt
Then I do not see the todo named Buy Yogurt
@serial
@delete_todo-message
Scenario: Should see the empty list message
When I empty the todo list
Then I see the empty list message is No tasks were found.

View File

@ -0,0 +1,12 @@
@open_app
Feature: Open App
@open_app-title
Scenario: Should see the home page title
When I open the app
Then I see the page title is My Tasks
@open_app-label
Scenario: Should see the input label
When I open the app
Then I see the label of the input is Add a Todo

View File

@ -0,0 +1,14 @@
mod fixtures;
use anyhow::Result;
use cucumber::World;
use fixtures::world::AppWorld;
#[tokio::main]
async fn main() -> Result<()> {
AppWorld::cucumber()
.fail_on_skipped()
.run_and_exit("./features")
.await;
Ok(())
}

View File

@ -0,0 +1,60 @@
use super::{find, world::HOST};
use anyhow::Result;
use fantoccini::Client;
use std::result::Result::Ok;
use tokio::{self, time};
pub async fn goto_path(client: &Client, path: &str) -> Result<()> {
let url = format!("{}{}", HOST, path);
client.goto(&url).await?;
Ok(())
}
pub async fn add_todo(client: &Client, text: &str) -> Result<()> {
fill_todo(client, text).await?;
click_add_button(client).await?;
Ok(())
}
pub async fn fill_todo(client: &Client, text: &str) -> Result<()> {
let textbox = find::todo_input(client).await;
textbox.send_keys(text).await?;
Ok(())
}
pub async fn click_add_button(client: &Client) -> Result<()> {
let add_button = find::add_button(client).await;
add_button.click().await?;
Ok(())
}
pub async fn empty_todo_list(client: &Client) -> Result<()> {
let todos = find::todos(client).await;
for _todo in todos {
let _ = delete_first_todo(client).await?;
}
Ok(())
}
pub async fn delete_first_todo(client: &Client) -> Result<()> {
if let Some(element) = find::first_delete_button(client).await {
element.click().await.expect("Failed to delete todo");
time::sleep(time::Duration::from_millis(250)).await;
}
Ok(())
}
pub async fn delete_todo(client: &Client, text: &str) -> Result<()> {
if let Some(element) = find::delete_button(client, text).await {
element.click().await?;
time::sleep(time::Duration::from_millis(250)).await;
}
Ok(())
}

View File

@ -0,0 +1,57 @@
use super::find;
use anyhow::{Ok, Result};
use fantoccini::{Client, Locator};
use pretty_assertions::assert_eq;
pub async fn text_on_element(
client: &Client,
selector: &str,
expected_text: &str,
) -> Result<()> {
let element = client
.wait()
.for_element(Locator::Css(selector))
.await
.expect(
format!("Element not found by Css selector `{}`", selector)
.as_str(),
);
let actual = element.text().await?;
assert_eq!(&actual, expected_text);
Ok(())
}
pub async fn todo_present(
client: &Client,
text: &str,
expected: bool,
) -> Result<()> {
let todo_present = is_todo_present(client, text).await;
assert_eq!(todo_present, expected);
Ok(())
}
async fn is_todo_present(client: &Client, text: &str) -> bool {
let todos = find::todos(client).await;
for todo in todos {
let todo_title = todo.text().await.expect("Todo title not found");
if todo_title == text {
return true;
}
}
false
}
pub async fn todo_is_pending(client: &Client) -> Result<()> {
if let None = find::pending_todo(client).await {
assert!(false, "Pending todo not found");
}
Ok(())
}

View File

@ -0,0 +1,63 @@
use fantoccini::{elements::Element, Client, Locator};
pub async fn todo_input(client: &Client) -> Element {
let textbox = client
.wait()
.for_element(Locator::Css("input[name='title"))
.await
.expect("Todo textbox not found");
textbox
}
pub async fn add_button(client: &Client) -> Element {
let button = client
.wait()
.for_element(Locator::Css("input[value='Add']"))
.await
.expect("");
button
}
pub async fn first_delete_button(client: &Client) -> Option<Element> {
if let Ok(element) = client
.wait()
.for_element(Locator::Css("li:first-child input[value='X']"))
.await
{
return Some(element);
}
None
}
pub async fn delete_button(client: &Client, text: &str) -> Option<Element> {
let selector = format!("//*[text()='{text}']//input[@value='X']");
if let Ok(element) =
client.wait().for_element(Locator::XPath(&selector)).await
{
return Some(element);
}
None
}
pub async fn pending_todo(client: &Client) -> Option<Element> {
if let Ok(element) =
client.wait().for_element(Locator::Css(".pending")).await
{
return Some(element);
}
None
}
pub async fn todos(client: &Client) -> Vec<Element> {
let todos = client
.find_all(Locator::Css("li"))
.await
.expect("Todo List not found");
todos
}

View File

@ -0,0 +1,4 @@
pub mod action;
pub mod check;
pub mod find;
pub mod world;

View File

@ -0,0 +1,57 @@
use crate::fixtures::{action, world::AppWorld};
use anyhow::{Ok, Result};
use cucumber::{given, when};
#[given("I see the app")]
#[when("I open the app")]
async fn i_open_the_app(world: &mut AppWorld) -> Result<()> {
let client = &world.client;
action::goto_path(client, "").await?;
Ok(())
}
#[given(regex = "^I add a todo as (.*)$")]
#[when(regex = "^I add a todo as (.*)$")]
async fn i_add_a_todo_titled(world: &mut AppWorld, text: String) -> Result<()> {
let client = &world.client;
action::add_todo(client, text.as_str()).await?;
Ok(())
}
#[given(regex = "^I set the todo as (.*)$")]
async fn i_set_the_todo_as(world: &mut AppWorld, text: String) -> Result<()> {
let client = &world.client;
action::fill_todo(client, &text).await?;
Ok(())
}
#[when(regex = "I click the Add button$")]
async fn i_click_the_button(world: &mut AppWorld) -> Result<()> {
let client = &world.client;
action::click_add_button(client).await?;
Ok(())
}
#[when(regex = "^I delete the todo named (.*)$")]
async fn i_delete_the_todo_named(
world: &mut AppWorld,
text: String,
) -> Result<()> {
let client = &world.client;
action::delete_todo(client, text.as_str()).await?;
Ok(())
}
#[given("the todo list is empty")]
#[when("I empty the todo list")]
async fn i_empty_the_todo_list(world: &mut AppWorld) -> Result<()> {
let client = &world.client;
action::empty_todo_list(client).await?;
Ok(())
}

View File

@ -0,0 +1,67 @@
use crate::fixtures::{check, world::AppWorld};
use anyhow::{Ok, Result};
use cucumber::then;
#[then(regex = "^I see the page title is (.*)$")]
async fn i_see_the_page_title_is(
world: &mut AppWorld,
text: String,
) -> Result<()> {
let client = &world.client;
check::text_on_element(client, "h1", &text).await?;
Ok(())
}
#[then(regex = "^I see the label of the input is (.*)$")]
async fn i_see_the_label_of_the_input_is(
world: &mut AppWorld,
text: String,
) -> Result<()> {
let client = &world.client;
check::text_on_element(client, "label", &text).await?;
Ok(())
}
#[then(regex = "^I see the todo named (.*)$")]
async fn i_see_the_todo_is_present(
world: &mut AppWorld,
text: String,
) -> Result<()> {
let client = &world.client;
check::todo_present(client, text.as_str(), true).await?;
Ok(())
}
#[then("I see the pending todo")]
async fn i_see_the_pending_todo(world: &mut AppWorld) -> Result<()> {
let client = &world.client;
check::todo_is_pending(client).await?;
Ok(())
}
#[then(regex = "^I see the empty list message is (.*)$")]
async fn i_see_the_empty_list_message_is(
world: &mut AppWorld,
text: String,
) -> Result<()> {
let client = &world.client;
check::text_on_element(client, "ul p", &text).await?;
Ok(())
}
#[then(regex = "^I do not see the todo named (.*)$")]
async fn i_do_not_see_the_todo_is_present(
world: &mut AppWorld,
text: String,
) -> Result<()> {
let client = &world.client;
check::todo_present(client, text.as_str(), false).await?;
Ok(())
}

View File

@ -0,0 +1,39 @@
pub mod action_steps;
pub mod check_steps;
use anyhow::Result;
use cucumber::World;
use fantoccini::{
error::NewSessionError, wd::Capabilities, Client, ClientBuilder,
};
pub const HOST: &str = "http://127.0.0.1:3000";
#[derive(Debug, World)]
#[world(init = Self::new)]
pub struct AppWorld {
pub client: Client,
}
impl AppWorld {
async fn new() -> Result<Self, anyhow::Error> {
let webdriver_client = build_client().await?;
Ok(Self {
client: webdriver_client,
})
}
}
async fn build_client() -> Result<Client, NewSessionError> {
let mut cap = Capabilities::new();
let arg = serde_json::from_str("{\"args\": [\"-headless\"]}").unwrap();
cap.insert("goog:chromeOptions".to_string(), arg);
let client = ClientBuilder::native()
.capabilities(cap)
.connect("http://localhost:4444")
.await?;
Ok(client)
}

View File

@ -0,0 +1,7 @@
CREATE TABLE IF NOT EXISTS todos
(
id INTEGER NOT NULL PRIMARY KEY,
title VARCHAR,
completed BOOLEAN
);

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -0,0 +1,2 @@
[toolchain]
channel = "nightly"

View File

@ -0,0 +1,199 @@
use crate::error_template::ErrorTemplate;
use leptos::{html::Input, *};
use leptos_meta::*;
use leptos_router::*;
use serde::{Deserialize, Serialize};
use server_fn::codec::SerdeLite;
#[cfg(feature = "ssr")]
use std::sync::{
atomic::{AtomicU8, Ordering},
Mutex,
};
#[component]
pub fn TodoApp() -> impl IntoView {
provide_meta_context();
view! {
<Link rel="shortcut icon" type_="image/ico" href="/favicon.ico"/>
<Stylesheet id="leptos" href="/pkg/server_fns_axum.css"/>
<Router>
<header>
<h1>"Server Function Demo"</h1>
</header>
<main>
<Routes>
<Route path="" view=HomePage/>
</Routes>
</main>
</Router>
}
}
#[component]
pub fn HomePage() -> impl IntoView {
view! {
<h2>"Some Simple Server Functions"</h2>
<SpawnLocal/>
<WithAnAction/>
<WithActionForm/>
}
}
/// A server function is really just an API call to your server. But it provides a plain async
/// function as a wrapper around that. This means you can call it like any other async code, just
/// by spawning a task with `spawn_local`.
///
/// In reality, you usually want to use a resource to load data from the server or an action to
/// mutate data on the server. But a simple `spawn_local` can make it more obvious what's going on.
#[component]
pub fn SpawnLocal() -> impl IntoView {
/// A basic server function can be called like any other async function.
///
/// You can define a server function at any scope. This one, for example, is only available
/// inside the SpawnLocal component. **However**, note that all server functions are publicly
/// available API endpoints: This scoping means you can only call this server function
/// from inside this component, but it is still available at its URL to any caller, from within
/// your app or elsewhere.
#[server]
pub async fn shouting_text(input: String) -> Result<String, ServerFnError> {
// insert a simulated wait
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
Ok(input.to_ascii_uppercase())
}
let input_ref = NodeRef::<Input>::new();
let (shout_result, set_shout_result) =
create_signal("Click me".to_string());
view! {
<h3>Using <code>spawn_local</code></h3>
<p>
"You can call a server function by using "<code>"spawn_local"</code> " in an event listener. "
"Clicking this button should alert with the uppercase version of the input."
</p>
<input node_ref=input_ref placeholder="Type something here."/>
<button
on:click=move |_| {
let value = input_ref.get().unwrap().value();
spawn_local(async move {
let uppercase_text = shouting_text(value).await.unwrap_or_else(|e| e.to_string());
set_shout_result(uppercase_text);
});
}
>
{shout_result}
</button>
}
}
/// Pretend this is a database and we're storing some rows in memory!
/// This exists only on the server.
#[cfg(feature = "ssr")]
static ROWS: Mutex<Vec<String>> = Mutex::new(Vec::new());
/// Imagine this server function mutates some state on the server, like a database row.
/// Every third time, it will return an error.
///
/// This kind of mutation is often best handled by an Action.
/// Remember, if you're loading data, use a resource; if you're running an occasional action,
/// use an action.
#[server]
pub async fn add_row(text: String) -> Result<usize, ServerFnError> {
static N: AtomicU8 = AtomicU8::new(0);
// insert a simulated wait
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
let nth_run = N.fetch_add(1, Ordering::Relaxed);
// this will print on the server, like any server function
println!("Adding {text:?} to the database!");
if nth_run % 3 == 2 {
Err(ServerFnError::new("Oh no! Couldn't add to database!"))
} else {
let mut rows = ROWS.lock().unwrap();
rows.push(text);
Ok(rows.len())
}
}
/// Simply returns the number of rows.
#[server]
pub async fn get_rows() -> Result<usize, ServerFnError> {
// insert a simulated wait
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
Ok(ROWS.lock().unwrap().len())
}
/// An action abstracts over the process of spawning a future and setting a signal when it
/// resolves. Its .input() signal holds the most recent argument while it's still pending,
/// and its .value() signal holds the most recent result. Its .version() signal can be fed
/// into a resource, telling it to refetch whenever the action has successfully resolved.
///
/// This makes actions useful for mutations, i.e., some server function that invalidates
/// loaded previously loaded from another server function.
#[component]
pub fn WithAnAction() -> impl IntoView {
let input_ref = NodeRef::<Input>::new();
// a server action can be created by using the server function's type name as a generic
// the type name defaults to the PascalCased function name
let action = create_server_action::<AddRow>();
// this resource will hold the total number of rows
// passing it action.version() means it will refetch whenever the action resolves successfully
let row_count = create_resource(action.version(), |_| get_rows());
view! {
<h3>Using <code>create_action</code></h3>
<p>
"Some server functions are conceptually \"mutations,\", which change something on the server. "
"These often work well as actions."
</p>
<input node_ref=input_ref placeholder="Type something here."/>
<button
on:click=move |_| {
let text = input_ref.get().unwrap().value();
action.dispatch(AddRow { text });
}
>
Submit
</button>
<p>You submitted: {move || format!("{:?}", action.input().get())}</p>
<p>The result was: {move || format!("{:?}", action.value().get())}</p>
<Transition>
<p>Total rows: {row_count}</p>
</Transition>
}
}
/// An <ActionForm/> lets you do the same thing as dispatching an action, but automates the
/// creation of the dispatched argument struct using a <form>. This means it also gracefully
/// degrades well when JS/WASM are not available.
#[component]
pub fn WithActionForm() -> impl IntoView {
let action = create_server_action::<AddRow>();
let row_count = create_resource(action.version(), |_| get_rows());
view! {
<h3>Using <code>create_action</code></h3>
<p>
<code>"<ActionForm/>"</code> "lets you use an HTML " <code>"<form>"</code>
"to call a server function in a way that gracefully degrades."
</p>
<ActionForm action>
<input
// the `name` of the input corresponds to the argument name
name="text"
placeholder="Type something here."
/>
<button> Submit </button>
</ActionForm>
<p>You submitted: {move || format!("{:?}", action.input().get())}</p>
<p>The result was: {move || format!("{:?}", action.value().get())}</p>
<Transition>
<p>Total rows: {row_count}</p>
</Transition>
}
}

View File

@ -0,0 +1,57 @@
use crate::errors::TodoAppError;
use leptos::{Errors, *};
#[cfg(feature = "ssr")]
use leptos_axum::ResponseOptions;
// A basic function to display errors served by the error boundaries. Feel free to do more complicated things
// here than just displaying them
#[component]
pub fn ErrorTemplate(
#[prop(optional)] outside_errors: Option<Errors>,
#[prop(optional)] errors: Option<RwSignal<Errors>>,
) -> impl IntoView {
let errors = match outside_errors {
Some(e) => create_rw_signal(e),
None => match errors {
Some(e) => e,
None => panic!("No Errors found and we expected errors!"),
},
};
// Get Errors from Signal
// Downcast lets us take a type that implements `std::error::Error`
let errors: Vec<TodoAppError> = errors
.get()
.into_iter()
.filter_map(|(_, v)| v.downcast_ref::<TodoAppError>().cloned())
.collect();
// Only the response code for the first error is actually sent from the server
// this may be customized by the specific application
#[cfg(feature = "ssr")]
{
let response = use_context::<ResponseOptions>();
if let Some(response) = response {
response.set_status(errors[0].status_code());
}
}
view! {
<h1>"Errors"</h1>
<For
// a function that returns the items we're iterating over; a signal is fine
each= move || {errors.clone().into_iter().enumerate()}
// a unique key for each item as a reference
key=|(index, _error)| *index
// renders each item to a view
children=move |error| {
let error_string = error.1.to_string();
let error_code= error.1.status_code();
view! {
<h2>{error_code.to_string()}</h2>
<p>"Error: " {error_string}</p>
}
}
/>
}
}

View File

@ -0,0 +1,21 @@
use http::status::StatusCode;
use thiserror::Error;
#[derive(Debug, Clone, Error)]
pub enum TodoAppError {
#[error("Not Found")]
NotFound,
#[error("Internal Server Error")]
InternalServerError,
}
impl TodoAppError {
pub fn status_code(&self) -> StatusCode {
match self {
TodoAppError::NotFound => StatusCode::NOT_FOUND,
TodoAppError::InternalServerError => {
StatusCode::INTERNAL_SERVER_ERROR
}
}
}
}

View File

@ -0,0 +1,50 @@
use crate::{error_template::ErrorTemplate, errors::TodoAppError};
use axum::{
body::Body,
extract::State,
http::{Request, Response, StatusCode, Uri},
response::{IntoResponse, Response as AxumResponse},
};
use leptos::{view, Errors, LeptosOptions};
use tower::ServiceExt;
use tower_http::services::ServeDir;
pub async fn file_and_error_handler(
uri: Uri,
State(options): State<LeptosOptions>,
req: Request<Body>,
) -> AxumResponse {
let root = options.site_root.clone();
let res = get_static_file(uri.clone(), &root).await.unwrap();
if res.status() == StatusCode::OK {
res.into_response()
} else {
let mut errors = Errors::default();
errors.insert_with_default_key(TodoAppError::NotFound);
let handler = leptos_axum::render_app_to_stream(
options.to_owned(),
move || view! {<ErrorTemplate outside_errors=errors.clone()/>},
);
handler(req).await.into_response()
}
}
async fn get_static_file(
uri: Uri,
root: &str,
) -> Result<Response<Body>, (StatusCode, String)> {
let req = Request::builder()
.uri(uri.clone())
.body(Body::empty())
.unwrap();
// `ServeDir` implements `tower::Service` so we can call it with `tower::ServiceExt::oneshot`
// This path is relative to the cargo root
match ServeDir::new(root).oneshot(req).await {
Ok(res) => Ok(res.into_response()),
Err(err) => Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("Something went wrong: {err}"),
)),
}
}

View File

@ -0,0 +1,15 @@
pub mod app;
pub mod error_template;
pub mod errors;
#[cfg(feature = "ssr")]
pub mod fallback;
#[wasm_bindgen::prelude::wasm_bindgen]
pub fn hydrate() {
use crate::app::TodoApp;
_ = console_log::init_with_level(log::Level::Error);
console_error_panic_hook::set_once();
leptos::mount_to_body(TodoApp);
}

View File

@ -0,0 +1,38 @@
use crate::{fallback::file_and_error_handler, app::*};
use axum::{
body::Body,
extract::{Path, State},
http::Request,
response::{IntoResponse, Response},
routing::get,
Router,
};
use leptos::*;
use leptos_axum::{generate_route_list, LeptosRoutes};
use server_fns_axum::*;
#[tokio::main]
async fn main() {
simple_logger::init_with_level(log::Level::Error)
.expect("couldn't initialize logging");
// Setting this to None means we'll be using cargo-leptos and its env vars
let conf = get_configuration(None).await.unwrap();
let leptos_options = conf.leptos_options;
let addr = leptos_options.site_addr;
let routes = generate_route_list(TodoApp);
// build our application with a route
let app = Router::new()
.leptos_routes(&leptos_options, routes, || view! { <TodoApp/> })
.fallback(file_and_error_handler)
.with_state(leptos_options);
// run our app with hyper
// `axum::Server` is a re-export of `hyper::Server`
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
logging::log!("listening on http://{}", &addr);
axum::serve(listener, app.into_make_service())
.await
.unwrap();
}

View File

@ -0,0 +1,3 @@
.pending {
color: purple;
}