From 629ac0148409a2df0d32095cf08b57e82af4f2dc Mon Sep 17 00:00:00 2001 From: Greg Johnston Date: Fri, 23 Dec 2022 14:23:38 -0500 Subject: [PATCH] merge `todo-app-sqlite-axum` --- examples/todo-app-sqlite-axum/Cargo.toml | 62 +++++ examples/todo-app-sqlite-axum/LICENSE | 21 ++ examples/todo-app-sqlite-axum/README.md | 22 ++ examples/todo-app-sqlite-axum/Todos.db | Bin 0 -> 16384 bytes .../20221118172000_create_todo_table.sql | 7 + examples/todo-app-sqlite-axum/src/lib.rs | 22 ++ examples/todo-app-sqlite-axum/src/main.rs | 81 +++++++ examples/todo-app-sqlite-axum/src/todo.rs | 227 ++++++++++++++++++ 8 files changed, 442 insertions(+) create mode 100644 examples/todo-app-sqlite-axum/Cargo.toml create mode 100644 examples/todo-app-sqlite-axum/LICENSE create mode 100644 examples/todo-app-sqlite-axum/README.md create mode 100644 examples/todo-app-sqlite-axum/Todos.db create mode 100644 examples/todo-app-sqlite-axum/migrations/20221118172000_create_todo_table.sql create mode 100644 examples/todo-app-sqlite-axum/src/lib.rs create mode 100644 examples/todo-app-sqlite-axum/src/main.rs create mode 100644 examples/todo-app-sqlite-axum/src/todo.rs diff --git a/examples/todo-app-sqlite-axum/Cargo.toml b/examples/todo-app-sqlite-axum/Cargo.toml new file mode 100644 index 0000000..3f09b98 --- /dev/null +++ b/examples/todo-app-sqlite-axum/Cargo.toml @@ -0,0 +1,62 @@ +[package] +name = "todo-app-sqlite-axum" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +anyhow = "1.0.66" +console_log = "0.2.0" +console_error_panic_hook = "0.1.7" +futures = "0.3.25" +cfg-if = "1.0.0" +leptos = { path = "../../../leptos/leptos", default-features = false, features = [ + "serde", +] } +leptos_axum = { path = "../../../leptos/integrations/axum", default-features = false, optional = true } +leptos_meta = { path = "../../../leptos/meta", default-features = false } +leptos_router = { path = "../../../leptos/router", default-features = false } +log = "0.4.17" +simple_logger = "4.0.0" +serde = { version = "1.0.148", features = ["derive"] } +serde_json = "1.0.89" +gloo-net = { version = "0.2.5", features = ["http"] } +reqwest = { version = "0.11.13", features = ["json"] } +axum = { version = "0.6.1", optional = true } +tower = { version = "0.4.13", optional = true } +tower-http = { version = "0.3.4", features = ["fs"], optional = true } +tokio = { version = "1.22.0", features = ["full"], optional = true } +http = { version = "0.2.8" } +sqlx = { version = "0.6.2", features = [ + "runtime-tokio-rustls", + "sqlite", +], optional = true } + +[features] +default = ["csr"] +csr = ["leptos/csr", "leptos_meta/csr", "leptos_router/csr"] +hydrate = ["leptos/hydrate", "leptos_meta/hydrate", "leptos_router/hydrate"] +ssr = [ + "dep:axum", + "dep:tower", + "dep:tower-http", + "dep:tokio", + "dep:sqlx", + "leptos/ssr", + "leptos_meta/ssr", + "leptos_router/ssr", + "leptos_axum", +] + +[package.metadata.cargo-all-features] +denylist = [ + "axum", + "tower", + "tower-http", + "tokio", + "sqlx", + "leptos_axum", +] +skip_feature_sets = [["csr", "ssr"], ["csr", "hydrate"], ["ssr", "hydrate"]] diff --git a/examples/todo-app-sqlite-axum/LICENSE b/examples/todo-app-sqlite-axum/LICENSE new file mode 100644 index 0000000..77d5625 --- /dev/null +++ b/examples/todo-app-sqlite-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/todo-app-sqlite-axum/README.md b/examples/todo-app-sqlite-axum/README.md new file mode 100644 index 0000000..c951fc9 --- /dev/null +++ b/examples/todo-app-sqlite-axum/README.md @@ -0,0 +1,22 @@ +# 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. + +## Server Side Rendering With Hydration + +To run it as a server side app with hydration, first you should run + +```bash +wasm-pack build --target=web --debug --no-default-features --features=hydrate +``` + +to generate the WebAssembly to hydrate the HTML that is generated on the server. + +Then run the server with `cargo run` to serve the server side rendered HTML and the WASM bundle for hydration. + +```bash +cargo run --no-default-features --features=ssr +``` + +> Note that if your hydration code changes, you will have to rerun the wasm-pack command above +> This should be temporary, and vastly improve once cargo-leptos becomes ready for prime time! diff --git a/examples/todo-app-sqlite-axum/Todos.db b/examples/todo-app-sqlite-axum/Todos.db new file mode 100644 index 0000000000000000000000000000000000000000..c9e91811510280b8e0884336ca7221cd3be5151a GIT binary patch literal 16384 zcmeI&y>HV%7zg09oi-o+FoGc!2*F+Q%s3)lHxju2niGHD_FJbHg+JO zE=4RbF@Zn=Cin*s6C>RiU}8cH(4{L9h>>&BM3A8yNTA;Q#`Dr(dLbhaXW3>g4#k(wX1y8u?Y`NM zVP^a9M9b$y^joeE3h{J0EvzrK(L>+%gZ4@MjkkR(6`4AA2tWV=5P$##AOHafKmY;|fWUtxkdy|Joxc@+0{oBIF#rGn literal 0 HcmV?d00001 diff --git a/examples/todo-app-sqlite-axum/migrations/20221118172000_create_todo_table.sql b/examples/todo-app-sqlite-axum/migrations/20221118172000_create_todo_table.sql new file mode 100644 index 0000000..3c2908e --- /dev/null +++ b/examples/todo-app-sqlite-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/todo-app-sqlite-axum/src/lib.rs b/examples/todo-app-sqlite-axum/src/lib.rs new file mode 100644 index 0000000..d1f2d69 --- /dev/null +++ b/examples/todo-app-sqlite-axum/src/lib.rs @@ -0,0 +1,22 @@ +use cfg_if::cfg_if; +use leptos::*; +pub mod todo; + +// Needs to be in lib.rs AFAIK because wasm-bindgen needs us to be compiling a lib. I may be wrong. +cfg_if! { + if #[cfg(feature = "hydrate")] { + use wasm_bindgen::prelude::wasm_bindgen; + use crate::todo::*; + + #[wasm_bindgen] + pub fn hydrate() { + console_error_panic_hook::set_once(); + _ = console_log::init_with_level(log::Level::Debug); + console_error_panic_hook::set_once(); + + leptos::mount_to_body(|cx| { + view! { cx, } + }); + } + } +} diff --git a/examples/todo-app-sqlite-axum/src/main.rs b/examples/todo-app-sqlite-axum/src/main.rs new file mode 100644 index 0000000..15b3b75 --- /dev/null +++ b/examples/todo-app-sqlite-axum/src/main.rs @@ -0,0 +1,81 @@ +use cfg_if::cfg_if; +use leptos::*; + +// boilerplate to run in different modes +cfg_if! { +if #[cfg(feature = "ssr")] { + use axum::{ + routing::{post}, + error_handling::HandleError, + Router, + }; + use std::net::SocketAddr; + use crate::todo::*; + use todo_app_sqlite_axum::*; + use http::StatusCode; + use tower_http::services::ServeDir; + use std::env; + + #[tokio::main] + async fn main() { + let addr = SocketAddr::from(([127, 0, 0, 1], 3000)); + log::debug!("serving at {addr}"); + + simple_logger::init_with_level(log::Level::Debug).expect("couldn't initialize logging"); + + let mut conn = db().await.expect("couldn't connect to DB"); + /* sqlx::migrate!() + .run(&mut conn) + .await + .expect("could not run SQLx migrations"); */ + + crate::todo::register_server_functions(); + + // These are Tower Services that will serve files from the static and pkg repos. + // HandleError is needed as Axum requires services to implement Infallible Errors + // because all Errors are converted into Responses + let static_service = HandleError::new( ServeDir::new("./static"), handle_file_error); + let pkg_service = HandleError::new( ServeDir::new("./pkg"), handle_file_error); + + /// Convert the Errors from ServeDir to a type that implements IntoResponse + async fn handle_file_error(err: std::io::Error) -> (StatusCode, String) { + ( + StatusCode::NOT_FOUND, + format!("File Not Found: {}", err), + ) + } + + + let render_options: RenderOptions = RenderOptions::builder().pkg_path("/pkg/todo_app_sqlite_axum").socket_address(addr).reload_port(3001).environment(&env::var("RUST_ENV")).build(); + render_options.write_to_file(); + // build our application with a route + let app = Router::new() + .route("/api/*fn_name", post(leptos_axum::handle_server_fns)) + .nest_service("/pkg", pkg_service) + .nest_service("/static", static_service) + .fallback(leptos_axum::render_app_to_stream(render_options.clone(), |cx| view! { cx, })); + + // run our app with hyper + // `axum::Server` is a re-export of `hyper::Server` + log!("listening on {}", &render_options.socket_address); + axum::Server::bind(&render_options.socket_address) + .serve(app.into_make_service()) + .await + .unwrap(); + } +} + + // client-only stuff for Trunk + else { + use todo_app_sqlite_axum::todo::*; + + pub fn main() { + console_error_panic_hook::set_once(); + _ = console_log::init_with_level(log::Level::Debug); + console_error_panic_hook::set_once(); + mount_to_body(|cx| { + view! { cx, } + }); + } + } +} diff --git a/examples/todo-app-sqlite-axum/src/todo.rs b/examples/todo-app-sqlite-axum/src/todo.rs new file mode 100644 index 0000000..2975a6b --- /dev/null +++ b/examples/todo-app-sqlite-axum/src/todo.rs @@ -0,0 +1,227 @@ +use cfg_if::cfg_if; +use leptos::*; +use leptos_meta::*; +use leptos_router::*; +use serde::{Deserialize, Serialize}; + +cfg_if! { + if #[cfg(feature = "ssr")] { + use sqlx::{Connection, SqliteConnection}; + use http::{header::SET_COOKIE, HeaderMap, HeaderValue, StatusCode}; + + pub async fn db() -> Result { + Ok(SqliteConnection::connect("sqlite:Todos.db").await.map_err(|e| ServerFnError::ServerError(e.to_string()))?) + } + + pub fn register_server_functions() { + GetTodos::register(); + AddTodo::register(); + DeleteTodo::register(); + } + + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] + pub struct Todo { + id: u16, + title: String, + completed: bool, + } + } else { + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] + pub struct Todo { + id: u16, + title: String, + completed: bool, + } + } +} + +#[server(GetTodos, "/api")] +pub async fn get_todos(cx: Scope) -> Result, ServerFnError> { + // this is just an example of how to access server context injected in the handlers + // http::Request doesn't implement Clone, so more work will be needed to do use_context() on this + let req_parts = use_context::(cx).unwrap(); + println!("\ncalling server fn"); + println!("Uri = {:?}", req_parts.uri); + + use futures::TryStreamExt; + + let mut conn = db().await?; + + let mut todos = Vec::new(); + let mut rows = sqlx::query_as::<_, Todo>("SELECT * FROM todos").fetch(&mut conn); + while let Some(row) = rows + .try_next() + .await + .map_err(|e| ServerFnError::ServerError(e.to_string()))? + { + todos.push(row); + } + + // Add a random header(because why not) + let mut res_headers = HeaderMap::new(); + res_headers.insert(SET_COOKIE, HeaderValue::from_str("fizz=buzz").unwrap()); + + let res_parts = leptos_axum::ResponseParts { + headers: res_headers, + status: Some(StatusCode::IM_A_TEAPOT), + }; + + let res_options_outer = use_context::(cx); + if let Some(res_options) = res_options_outer { + res_options.overwrite(res_parts).await; + } + + Ok(todos) +} + +#[server(AddTodo, "/api")] +pub async fn add_todo(title: String) -> Result<(), ServerFnError> { + let mut conn = db().await?; + + // fake API delay + std::thread::sleep(std::time::Duration::from_millis(1250)); + + match sqlx::query("INSERT INTO todos (title, completed) VALUES ($1, false)") + .bind(title) + .execute(&mut conn) + .await + { + Ok(_row) => Ok(()), + Err(e) => Err(ServerFnError::ServerError(e.to_string())), + } +} + +#[server(DeleteTodo, "/api")] +pub async fn delete_todo(id: u16) -> Result<(), ServerFnError> { + let mut conn = db().await?; + + sqlx::query("DELETE FROM todos WHERE id = $1") + .bind(id) + .execute(&mut conn) + .await + .map(|_| ()) + .map_err(|e| ServerFnError::ServerError(e.to_string())) +} + +#[component] +pub fn TodoApp(cx: Scope) -> impl IntoView { + view! { + cx, + + +
+

"My Tasks"

+
+
+ + + }/> + +
+
+ } +} + +#[component] +pub fn Todos(cx: Scope) -> impl IntoView { + let add_todo = create_server_multi_action::(cx); + let delete_todo = create_server_action::(cx); + let submissions = add_todo.submissions(); + + // track mutations that should lead us to refresh the list + let add_changed = add_todo.version; + let todo_deleted = delete_todo.version; + + // list of todos is loaded from the server in reaction to changes + let todos = create_resource( + cx, + move || (add_changed(), todo_deleted()), + move |_| get_todos(cx), + ); + + view! { + cx, +
+ + + + + "Loading..."

}> + { + let delete_todo = delete_todo.clone(); + move || { + let existing_todos = { + let delete_todo = delete_todo.clone(); + move || { + todos + .read() + .map({ + let delete_todo = delete_todo.clone(); + move |todos| match todos { + Err(e) => { + vec![view! { cx,
"Server Error: " {e.to_string()}
}.into_any()] + } + Ok(todos) => { + if todos.is_empty() { + vec![view! { cx,

"No tasks were found."

}.into_any()] + } else { + todos + .into_iter() + .map({ + let delete_todo = delete_todo.clone(); + move |todo| { + let delete_todo = delete_todo.clone(); + view! { + cx, +
  • + {todo.title} + + + + +
  • + } + .into_any() + } + }) + .collect::>() + } + } + } + }) + .unwrap_or_default() + } + }; + + let pending_todos = move || { + submissions + .get() + .into_iter() + .filter(|submission| submission.pending().get()) + .map(|submission| { + view! { + cx, +
  • {move || submission.input.get().map(|data| data.title) }
  • + } + }) + .collect::>() + }; + + view! { + cx, +
      + {existing_todos} + {pending_todos} +
    + } + } + } +
    +
    + } +}