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 000000000..ec85d2b07 Binary files /dev/null and b/examples/server_fns_axum/Todos.db differ diff --git a/examples/server_fns_axum/e2e/Cargo.toml b/examples/server_fns_axum/e2e/Cargo.toml new file mode 100644 index 000000000..722428675 --- /dev/null +++ b/examples/server_fns_axum/e2e/Cargo.toml @@ -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 diff --git a/examples/server_fns_axum/e2e/Makefile.toml b/examples/server_fns_axum/e2e/Makefile.toml new file mode 100644 index 000000000..cd76be24d --- /dev/null +++ b/examples/server_fns_axum/e2e/Makefile.toml @@ -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", + "${@}", +] diff --git a/examples/server_fns_axum/e2e/README.md b/examples/server_fns_axum/e2e/README.md new file mode 100644 index 000000000..026f2befd --- /dev/null +++ b/examples/server_fns_axum/e2e/README.md @@ -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 +``` diff --git a/examples/server_fns_axum/e2e/features/add_todo.feature b/examples/server_fns_axum/e2e/features/add_todo.feature new file mode 100644 index 000000000..b2a1331ca --- /dev/null +++ b/examples/server_fns_axum/e2e/features/add_todo.feature @@ -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 diff --git a/examples/server_fns_axum/e2e/features/delete_todo.feature b/examples/server_fns_axum/e2e/features/delete_todo.feature new file mode 100644 index 000000000..3c1e743d2 --- /dev/null +++ b/examples/server_fns_axum/e2e/features/delete_todo.feature @@ -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. \ No newline at end of file diff --git a/examples/server_fns_axum/e2e/features/open_app.feature b/examples/server_fns_axum/e2e/features/open_app.feature new file mode 100644 index 000000000..f4b4e3952 --- /dev/null +++ b/examples/server_fns_axum/e2e/features/open_app.feature @@ -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 \ No newline at end of file diff --git a/examples/server_fns_axum/e2e/tests/app_suite.rs b/examples/server_fns_axum/e2e/tests/app_suite.rs new file mode 100644 index 000000000..5c56b6aca --- /dev/null +++ b/examples/server_fns_axum/e2e/tests/app_suite.rs @@ -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(()) +} 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 000000000..2ba8527cb Binary files /dev/null and b/examples/server_fns_axum/public/favicon.ico differ diff --git a/examples/server_fns_axum/rust-toolchain.toml b/examples/server_fns_axum/rust-toolchain.toml new file mode 100644 index 000000000..5d56faf9a --- /dev/null +++ b/examples/server_fns_axum/rust-toolchain.toml @@ -0,0 +1,2 @@ +[toolchain] +channel = "nightly" diff --git a/examples/server_fns_axum/src/app.rs b/examples/server_fns_axum/src/app.rs new file mode 100644 index 000000000..ddfc25510 --- /dev/null +++ b/examples/server_fns_axum/src/app.rs @@ -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! { + + + +
+

"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; +}