From 13a2691806e358c47c09e2df6643db78b4b856ed Mon Sep 17 00:00:00 2001 From: Greg Johnston Date: Sun, 14 Jan 2024 16:17:06 -0500 Subject: [PATCH] working on server fn example --- examples/server_fns_axum/Cargo.toml | 89 ++++++++ examples/server_fns_axum/LICENSE | 21 ++ examples/server_fns_axum/Makefile.toml | 12 ++ examples/server_fns_axum/README.md | 19 ++ examples/server_fns_axum/Todos.db | Bin 0 -> 16384 bytes examples/server_fns_axum/e2e/Cargo.toml | 18 ++ examples/server_fns_axum/e2e/Makefile.toml | 20 ++ examples/server_fns_axum/e2e/README.md | 34 +++ .../e2e/features/add_todo.feature | 16 ++ .../e2e/features/delete_todo.feature | 18 ++ .../e2e/features/open_app.feature | 12 ++ .../server_fns_axum/e2e/tests/app_suite.rs | 14 ++ .../e2e/tests/fixtures/action.rs | 60 ++++++ .../e2e/tests/fixtures/check.rs | 57 +++++ .../e2e/tests/fixtures/find.rs | 63 ++++++ .../server_fns_axum/e2e/tests/fixtures/mod.rs | 4 + .../e2e/tests/fixtures/world/action_steps.rs | 57 +++++ .../e2e/tests/fixtures/world/check_steps.rs | 67 ++++++ .../e2e/tests/fixtures/world/mod.rs | 39 ++++ .../20221118172000_create_todo_table.sql | 7 + examples/server_fns_axum/public/favicon.ico | Bin 0 -> 15406 bytes examples/server_fns_axum/rust-toolchain.toml | 2 + examples/server_fns_axum/src/app.rs | 199 ++++++++++++++++++ .../server_fns_axum/src/error_template.rs | 57 +++++ examples/server_fns_axum/src/errors.rs | 21 ++ examples/server_fns_axum/src/fallback.rs | 50 +++++ examples/server_fns_axum/src/lib.rs | 15 ++ examples/server_fns_axum/src/main.rs | 38 ++++ examples/server_fns_axum/style.css | 3 + 29 files changed, 1012 insertions(+) create mode 100644 examples/server_fns_axum/Cargo.toml create mode 100644 examples/server_fns_axum/LICENSE create mode 100644 examples/server_fns_axum/Makefile.toml create mode 100644 examples/server_fns_axum/README.md create mode 100644 examples/server_fns_axum/Todos.db create mode 100644 examples/server_fns_axum/e2e/Cargo.toml create mode 100644 examples/server_fns_axum/e2e/Makefile.toml create mode 100644 examples/server_fns_axum/e2e/README.md create mode 100644 examples/server_fns_axum/e2e/features/add_todo.feature create mode 100644 examples/server_fns_axum/e2e/features/delete_todo.feature create mode 100644 examples/server_fns_axum/e2e/features/open_app.feature create mode 100644 examples/server_fns_axum/e2e/tests/app_suite.rs create mode 100644 examples/server_fns_axum/e2e/tests/fixtures/action.rs create mode 100644 examples/server_fns_axum/e2e/tests/fixtures/check.rs create mode 100644 examples/server_fns_axum/e2e/tests/fixtures/find.rs create mode 100644 examples/server_fns_axum/e2e/tests/fixtures/mod.rs create mode 100644 examples/server_fns_axum/e2e/tests/fixtures/world/action_steps.rs create mode 100644 examples/server_fns_axum/e2e/tests/fixtures/world/check_steps.rs create mode 100644 examples/server_fns_axum/e2e/tests/fixtures/world/mod.rs create mode 100644 examples/server_fns_axum/migrations/20221118172000_create_todo_table.sql create mode 100644 examples/server_fns_axum/public/favicon.ico create mode 100644 examples/server_fns_axum/rust-toolchain.toml create mode 100644 examples/server_fns_axum/src/app.rs create mode 100644 examples/server_fns_axum/src/error_template.rs create mode 100644 examples/server_fns_axum/src/errors.rs create mode 100644 examples/server_fns_axum/src/fallback.rs create mode 100644 examples/server_fns_axum/src/lib.rs create mode 100644 examples/server_fns_axum/src/main.rs create mode 100644 examples/server_fns_axum/style.css diff --git a/examples/server_fns_axum/Cargo.toml b/examples/server_fns_axum/Cargo.toml new file mode 100644 index 000000000..4d518bb27 --- /dev/null +++ b/examples/server_fns_axum/Cargo.toml @@ -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 //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 diff --git a/examples/server_fns_axum/LICENSE b/examples/server_fns_axum/LICENSE new file mode 100644 index 000000000..77d5625cb --- /dev/null +++ b/examples/server_fns_axum/LICENSE @@ -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. diff --git a/examples/server_fns_axum/Makefile.toml b/examples/server_fns_axum/Makefile.toml new file mode 100644 index 000000000..d5fd86f31 --- /dev/null +++ b/examples/server_fns_axum/Makefile.toml @@ -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", "${@}"] diff --git a/examples/server_fns_axum/README.md b/examples/server_fns_axum/README.md new file mode 100644 index 000000000..b4f1639c4 --- /dev/null +++ b/examples/server_fns_axum/README.md @@ -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. diff --git a/examples/server_fns_axum/Todos.db b/examples/server_fns_axum/Todos.db new file mode 100644 index 0000000000000000000000000000000000000000..ec85d2b07f9ac6b3b931f4599e3e7a35107050f5 GIT binary patch literal 16384 zcmeI&KTH!*90%}sE!UPtcp%22i!UxpV!;*@34?ltqZCT3$6;jgtk;KLXgTOzl^7O` zi%w1&P5iUy>R^n6qiha3x#^%?j2nqYH{WY%qfKPP!F*ru_4U2q?_J-0uFExV_Dt6C zIa_cm<$}+KNe@vJa*8oRNLthmQJWde$qS)H{<+2JS?@0bHp;Y2UOU5%Z`mCwVoNgttaeYFY%bIL#&M@?xX|+-s)4i%~bI)U`d_Jpd zxz@Z!)V|E^MXy?Bscb&g7UQejuF6$ezEkGS9XJ^b#b)CQah8gFRX~YHXRA>4 z-Fn|@$FLIZBl7V%BJbsTND0ScF=c(Rj_$if*Q>t;{@|?-MWv}zl&*~;NcFUCwASA4 z>pOmlZ{?oVjIl}0ke6<|<>eChxjfSrO?04(ZjvCa5&DyUr8T-qKhT%-S@X4nKRcWn z0uX=z1Rwwb2tWV=5P$##AOL~C3551_klx7DwA$Z6Xk<#H9q5asCo`eFay&gL2EsIw zR`QD5!|VD&u%$)m|+JdubeMp$BGG9WPeWhsR@d?$gV&zFe`P!h=T@TPx)l1I khyw)z5P$##AOHafKmY;|fB*y_0D=EXpfjj;HvU%l1 Result<()> { + AppWorld::cucumber() + .fail_on_skipped() + .run_and_exit("./features") + .await; + Ok(()) +} diff --git a/examples/server_fns_axum/e2e/tests/fixtures/action.rs b/examples/server_fns_axum/e2e/tests/fixtures/action.rs new file mode 100644 index 000000000..79b5c685e --- /dev/null +++ b/examples/server_fns_axum/e2e/tests/fixtures/action.rs @@ -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(()) +} diff --git a/examples/server_fns_axum/e2e/tests/fixtures/check.rs b/examples/server_fns_axum/e2e/tests/fixtures/check.rs new file mode 100644 index 000000000..f43629b95 --- /dev/null +++ b/examples/server_fns_axum/e2e/tests/fixtures/check.rs @@ -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(()) +} diff --git a/examples/server_fns_axum/e2e/tests/fixtures/find.rs b/examples/server_fns_axum/e2e/tests/fixtures/find.rs new file mode 100644 index 000000000..228fce6a2 --- /dev/null +++ b/examples/server_fns_axum/e2e/tests/fixtures/find.rs @@ -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 { + 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 { + 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 { + if let Ok(element) = + client.wait().for_element(Locator::Css(".pending")).await + { + return Some(element); + } + + None +} + +pub async fn todos(client: &Client) -> Vec { + let todos = client + .find_all(Locator::Css("li")) + .await + .expect("Todo List not found"); + + todos +} diff --git a/examples/server_fns_axum/e2e/tests/fixtures/mod.rs b/examples/server_fns_axum/e2e/tests/fixtures/mod.rs new file mode 100644 index 000000000..72b1bd65e --- /dev/null +++ b/examples/server_fns_axum/e2e/tests/fixtures/mod.rs @@ -0,0 +1,4 @@ +pub mod action; +pub mod check; +pub mod find; +pub mod world; diff --git a/examples/server_fns_axum/e2e/tests/fixtures/world/action_steps.rs b/examples/server_fns_axum/e2e/tests/fixtures/world/action_steps.rs new file mode 100644 index 000000000..5c4e062db --- /dev/null +++ b/examples/server_fns_axum/e2e/tests/fixtures/world/action_steps.rs @@ -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(()) +} diff --git a/examples/server_fns_axum/e2e/tests/fixtures/world/check_steps.rs b/examples/server_fns_axum/e2e/tests/fixtures/world/check_steps.rs new file mode 100644 index 000000000..3e51215db --- /dev/null +++ b/examples/server_fns_axum/e2e/tests/fixtures/world/check_steps.rs @@ -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(()) +} diff --git a/examples/server_fns_axum/e2e/tests/fixtures/world/mod.rs b/examples/server_fns_axum/e2e/tests/fixtures/world/mod.rs new file mode 100644 index 000000000..c25a92570 --- /dev/null +++ b/examples/server_fns_axum/e2e/tests/fixtures/world/mod.rs @@ -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 { + let webdriver_client = build_client().await?; + + Ok(Self { + client: webdriver_client, + }) + } +} + +async fn build_client() -> Result { + 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) +} diff --git a/examples/server_fns_axum/migrations/20221118172000_create_todo_table.sql b/examples/server_fns_axum/migrations/20221118172000_create_todo_table.sql new file mode 100644 index 000000000..3c2908e53 --- /dev/null +++ b/examples/server_fns_axum/migrations/20221118172000_create_todo_table.sql @@ -0,0 +1,7 @@ + +CREATE TABLE IF NOT EXISTS todos +( + id INTEGER NOT NULL PRIMARY KEY, + title VARCHAR, + completed BOOLEAN +); \ No newline at end of file diff --git a/examples/server_fns_axum/public/favicon.ico b/examples/server_fns_axum/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..2ba8527cb12f5f28f331b8d361eef560492d4c77 GIT binary patch literal 15406 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 k<4FtN!5 impl IntoView { + provide_meta_context(); + + view! { + + + +
+

"Server Function Demo"

+
+
+ + + +
+
+ } +} + +#[component] +pub fn HomePage() -> impl IntoView { + view! { +

"Some Simple Server Functions"

+ + + + } +} + +/// 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 { + // insert a simulated wait + tokio::time::sleep(std::time::Duration::from_millis(250)).await; + Ok(input.to_ascii_uppercase()) + } + + let input_ref = NodeRef::::new(); + let (shout_result, set_shout_result) = + create_signal("Click me".to_string()); + + view! { +

Using spawn_local

+

+ "You can call a server function by using ""spawn_local" " in an event listener. " + "Clicking this button should alert with the uppercase version of the input." +

+ + + } +} + +/// 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> = 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 { + 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 { + // 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::::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::(); + + // 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! { +

Using create_action

+

+ "Some server functions are conceptually \"mutations,\", which change something on the server. " + "These often work well as actions." +

+ + +

You submitted: {move || format!("{:?}", action.input().get())}

+

The result was: {move || format!("{:?}", action.value().get())}

+ +

Total rows: {row_count}

+
+ } +} + +/// An lets you do the same thing as dispatching an action, but automates the +/// creation of the dispatched argument struct using a
. This means it also gracefully +/// degrades well when JS/WASM are not available. +#[component] +pub fn WithActionForm() -> impl IntoView { + let action = create_server_action::(); + let row_count = create_resource(action.version(), |_| get_rows()); + + view! { +

Using create_action

+

+ "" "lets you use an HTML " "" + "to call a server function in a way that gracefully degrades." +

+ + + + +

You submitted: {move || format!("{:?}", action.input().get())}

+

The result was: {move || format!("{:?}", action.value().get())}

+ +

Total rows: {row_count}

+
+ } +} diff --git a/examples/server_fns_axum/src/error_template.rs b/examples/server_fns_axum/src/error_template.rs new file mode 100644 index 000000000..0a1731abe --- /dev/null +++ b/examples/server_fns_axum/src/error_template.rs @@ -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, + #[prop(optional)] errors: Option>, +) -> 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 = errors + .get() + .into_iter() + .filter_map(|(_, v)| v.downcast_ref::().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::(); + if let Some(response) = response { + response.set_status(errors[0].status_code()); + } + } + + view! { +

"Errors"

+ {error_code.to_string()} +

"Error: " {error_string}

+ } + } + /> + } +} diff --git a/examples/server_fns_axum/src/errors.rs b/examples/server_fns_axum/src/errors.rs new file mode 100644 index 000000000..1f1aea92f --- /dev/null +++ b/examples/server_fns_axum/src/errors.rs @@ -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 + } + } + } +} diff --git a/examples/server_fns_axum/src/fallback.rs b/examples/server_fns_axum/src/fallback.rs new file mode 100644 index 000000000..66b2a5ffe --- /dev/null +++ b/examples/server_fns_axum/src/fallback.rs @@ -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, + req: Request, +) -> 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! {}, + ); + handler(req).await.into_response() + } +} + +async fn get_static_file( + uri: Uri, + root: &str, +) -> Result, (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}"), + )), + } +} diff --git a/examples/server_fns_axum/src/lib.rs b/examples/server_fns_axum/src/lib.rs new file mode 100644 index 000000000..36750be4e --- /dev/null +++ b/examples/server_fns_axum/src/lib.rs @@ -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); +} diff --git a/examples/server_fns_axum/src/main.rs b/examples/server_fns_axum/src/main.rs new file mode 100644 index 000000000..ba03453b4 --- /dev/null +++ b/examples/server_fns_axum/src/main.rs @@ -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! { }) + .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(); +} diff --git a/examples/server_fns_axum/style.css b/examples/server_fns_axum/style.css new file mode 100644 index 000000000..9ba7149f9 --- /dev/null +++ b/examples/server_fns_axum/style.css @@ -0,0 +1,3 @@ +.pending { + color: purple; +}