From 507191e1a42594e1ff06ee2f4f9d25ebe1712bed Mon Sep 17 00:00:00 2001 From: Ben Wishovich Date: Sun, 27 Nov 2022 16:55:38 -0800 Subject: [PATCH] Mostly working version of axum with server functions --- Cargo.toml | 1 + examples/todo-app-sqlite-axum/Cargo.toml | 45 ++++ examples/todo-app-sqlite-axum/LICENSE | 21 ++ examples/todo-app-sqlite-axum/README.md | 21 ++ examples/todo-app-sqlite-axum/Todos.db | Bin 0 -> 16384 bytes .../20221118172000_create_todo_table.sql | 6 + examples/todo-app-sqlite-axum/src/handlers.rs | 63 ++++++ examples/todo-app-sqlite-axum/src/lib.rs | 23 ++ examples/todo-app-sqlite-axum/src/main.rs | 187 +++++++++++++++ examples/todo-app-sqlite-axum/src/todo.rs | 213 ++++++++++++++++++ examples/todo-app-sqlite/Todos.db | Bin 16384 -> 16384 bytes examples/todo-app-sqlite/src/main.rs | 2 +- integrations/axum/src/lib.rs | 13 +- 13 files changed, 589 insertions(+), 6 deletions(-) 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/handlers.rs 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/Cargo.toml b/Cargo.toml index a7d1b68..1967f0d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,6 +28,7 @@ members = [ "examples/router", "examples/todomvc", "examples/todo-app-sqlite", + "examples/todo-app-sqlite-axum", "examples/todo-app-cbor", "examples/view-tests", diff --git a/examples/todo-app-sqlite-axum/Cargo.toml b/examples/todo-app-sqlite-axum/Cargo.toml new file mode 100644 index 0000000..0d96bc6 --- /dev/null +++ b/examples/todo-app-sqlite-axum/Cargo.toml @@ -0,0 +1,45 @@ +[package] +name = "todo-app-sqlite-axum" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +anyhow = "1" +console_log = "0.2" +console_error_panic_hook = "0.1" +futures = "0.3" +cfg-if = "1" +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" +simple_logger = "2" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +gloo-net = { version = "0.2", features = ["http"] } +reqwest = { version = "0.11", features = ["json"] } +axum = { version = "0.5.17", optional=true } +tower = { version = "0.4.13", optional=true } +tower-http = { version = "0.3.4", features = ["fs"], optional = true } +tokio = { version = "1.0", features = ["full"], optional = true } +http = {version = "0.2.8", optional = true} +sqlx = { version = "0.6", 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:http", "dep:sqlx", "leptos/ssr", "leptos_meta/ssr", "leptos_router/ssr", "leptos_axum"] + +[package.metadata.cargo-all-features] +denylist = ["axum", "tower", "tower-http", "tokio", "http", "sqlx", "leptos_actix"] +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..fe1831a --- /dev/null +++ b/examples/todo-app-sqlite-axum/README.md @@ -0,0 +1,21 @@ +# 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 + +## Client Side Rendering +To run it as a Client Side App, you can issue `trunk serve --open` in the root. This will build the entire +app into one CRS bundle + +## 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 --no-default-features --features=hydrate +``` +to generate the Webassembly to provide hydration features for 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! \ No newline at end of file diff --git a/examples/todo-app-sqlite-axum/Todos.db b/examples/todo-app-sqlite-axum/Todos.db new file mode 100644 index 0000000000000000000000000000000000000000..268b84c65e5857a844ac3efb634391ee622b8dc0 GIT binary patch literal 16384 zcmeI&F>ljA6bJCLoi>d{YXn0I(}@Ktk(Q)LR2>LTF(txHg6kqGB#g1IVAZMH_yQH` zQp5ri69^<=f-iuW80p3U6I&UeOLstwoRcO%hHfB%{!hLnckeEF{&~9SX3v`eXLKQI z)Ll-;$zCE!m#t7}O%749P^hIV5YNyUa!k0uX=z z1Rwwb2tWV=5P$##{-D6^5jmaB<)k}(+`SqyCte9xoqBMs;qo9_ihKLYlx66)L2bQc z8nib{kEm3{4c3UoVp=Lsmn$}{S>+ksny2TC`J=78&tk6;EGIueZR4U%D^=0vOtYO1 zmSXOPA@iLiU!E~$ZGEQJokUL?XY@JKrc-m4WmIgZJEgrmZh9VzV_K?KO+&AA*R`|W zb>=O`%{nca)lyH6tun8fRORrX&N}6Hic?35N`H1XCy`*uXR88AY@Hpq$)i@^>BVsJ zy(7u?c_4X~>-|zXo6Sn=i!F2>`BB_@k$>ZD4XKG~;3ux_5TwSmVRdWo_2mH%+0NVx zx;1rHw~|};qWW^kI7_~1hejP46vZgormSsiU$o7nGz0`7009U<00Izz00bZa0SG_< z0{@hNno5xa1GR{|JW9(cvVWjhEE1wBKZle3zob1T;zB?G0uX=z1Rwwb2tWV=5P$## zAn?}(d@0>GHl7KUeg5$Mw^zIdbHzdZ Result, (StatusCode, String)> { + let res = get_static_file(uri.clone(), "/pkg").await?; + + if res.status() == StatusCode::NOT_FOUND { + // try with `.html` + // TODO: handle if the Uri has query parameters + match format!("{}.html", uri).parse() { + Ok(uri_html) => get_static_file(uri_html, "/pkg").await, + Err(_) => Err((StatusCode::INTERNAL_SERVER_ERROR, "Invalid URI".to_string())), + } + } else { + Ok(res) + } + } + + pub async fn get_static_file_handler(uri: Uri) -> Result, (StatusCode, String)> { + let res = get_static_file(uri.clone(), "/static").await?; + + if res.status() == StatusCode::NOT_FOUND { + Err((StatusCode::INTERNAL_SERVER_ERROR, "Invalid URI".to_string())) + } else { + Ok(res) + } + } + + async fn get_static_file(uri: Uri, base: &str) -> Result, (StatusCode, String)> { + let req = Request::builder().uri(&uri).body(Body::empty()).unwrap(); + + // `ServeDir` implements `tower::Service` so we can call it with `tower::ServiceExt::oneshot` + // When run normally, the root should be the crate root + if base == "/static" { + match ServeDir::new("./static").oneshot(req).await { + Ok(res) => Ok(res.map(boxed)), + Err(err) => Err(( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Something went wrong: {}", err), + )) + } + } else if base == "/pkg" { + match ServeDir::new("./pkg").oneshot(req).await { + Ok(res) => Ok(res.map(boxed)), + Err(err) => Err(( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Something went wrong: {}", err), + )), + } + } else{ + Err((StatusCode::NOT_FOUND, "Not Found".to_string())) + } + } + } +} 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..07efd88 --- /dev/null +++ b/examples/todo-app-sqlite-axum/src/lib.rs @@ -0,0 +1,23 @@ +use cfg_if::cfg_if; +use leptos::*; +pub mod handlers; +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::hydrate(body().unwrap(), |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..ba458ef --- /dev/null +++ b/examples/todo-app-sqlite-axum/src/main.rs @@ -0,0 +1,187 @@ +// use cfg_if::cfg_if; +// use futures::StreamExt; +// use leptos::*; +// use leptos_meta::*; +// use leptos_router::*; +// mod todo; + +// // boilerplate to run in different modes +// cfg_if! { +// // server-only stuff +// if #[cfg(feature = "ssr")] { +// use actix_files::{Files}; +// use actix_web::*; +// use crate::todo::*; + +// #[get("{tail:.*}")] +// async fn render(req: HttpRequest) -> impl Responder { +// let path = req.path(); + +// let query = req.query_string(); +// let path = if query.is_empty() { +// "http://leptos".to_string() + path +// } else { +// "http://leptos".to_string() + path + "?" + query +// }; + +// let app = move |cx| { +// let integration = ServerIntegration { path: path.clone() }; +// provide_context(cx, RouterIntegrationContext::new(integration)); +// provide_context(cx, req.clone()); + +// view! { cx, } +// }; + +// let head = r#" +// +// +// +// +// "#; +// let tail = ""; + +// HttpResponse::Ok().content_type("text/html").streaming( +// futures::stream::once(async { head.to_string() }) +// .chain(render_to_stream(move |cx| { +// let app = app(cx); +// let head = use_context::(cx) +// .map(|meta| meta.dehydrate()) +// .unwrap_or_default(); +// format!("{head}{app}") +// })) +// .chain(futures::stream::once(async { tail.to_string() })) +// .map(|html| Ok(web::Bytes::from(html)) as Result), +// ) +// } + +// #[post("/api/{tail:.*}")] +// async fn handle_server_fns( +// req: HttpRequest, +// params: web::Path, +// body: web::Bytes, +// ) -> impl Responder { +// let path = params.into_inner(); +// let accept_header = req +// .headers() +// .get("Accept") +// .and_then(|value| value.to_str().ok()); + +// if let Some(server_fn) = server_fn_by_path(path.as_str()) { +// let body: &[u8] = &body; +// let (cx, disposer) = raw_scope_and_disposer(); +// provide_context(cx, req.clone()); +// match server_fn(cx, &body).await { +// Ok(serialized) => { +// // if this is Accept: application/json then send a serialized JSON response +// if let Some("application/json") = accept_header { +// HttpResponse::Ok().body(serialized) +// } +// // otherwise, it's probably a
submit or something: redirect back to the referrer +// else { +// HttpResponse::SeeOther() +// .insert_header(("Location", "/")) +// .content_type("application/json") +// .body(serialized) +// } +// } +// Err(e) => { +// eprintln!("server function error: {e:#?}"); +// HttpResponse::InternalServerError().body(e.to_string()) +// } +// } +// } else { +// HttpResponse::BadRequest().body(format!("Could not find a server function at that route.")) +// } +// } + +// #[actix_web::main] +// async fn main() -> std::io::Result<()> { +// 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(); + +// HttpServer::new(|| { +// App::new() +// .service(Files::new("/pkg", "./pkg")) +// .service(handle_server_fns) +// .service(render) +// //.wrap(middleware::Compress::default()) +// }) +// .bind(("127.0.0.1", 8081))? +// .run() +// .await +// } +// } else { +// fn main() { +// // no client-side main function +// } +// } +// } +use cfg_if::cfg_if; +use leptos::*; + +// boilerplate to run in different modes +cfg_if! { +if #[cfg(feature = "ssr")] { + // use actix_files::{Files, NamedFile}; + // use actix_web::*; + use axum::{ + routing::{get, post}, + Router, + handler::Handler, + }; + use std::net::SocketAddr; + use crate::todo::*; + use todo_app_sqlite_axum::handlers::{file_handler, get_static_file_handler}; + use todo_app_sqlite_axum::*; + + #[tokio::main] + async fn main() { + let addr = SocketAddr::from(([127, 0, 0, 1], 8082)); + 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(); + + // build our application with a route + let app = Router::new() + .route("/api/*path", post(leptos_axum::handle_server_fns)) + .nest("/pkg", get(file_handler)) + .nest("/static", get(get_static_file_handler)) + .fallback(leptos_axum::render_app_to_stream("todo_app_sqlite_axum", |cx| view! { cx, }).into_service()); + + // run our app with hyper + // `axum::Server` is a re-export of `hyper::Server` + log!("listening on {}", addr); + axum::Server::bind(&addr) + .serve(app.into_make_service()) + .await + .unwrap(); + } +} + + // client-only stuff for Trunk + else { + use leptos_hackernews_axum::*; + + 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..c60af7f --- /dev/null +++ b/examples/todo-app-sqlite-axum/src/todo.rs @@ -0,0 +1,213 @@ +use cfg_if::cfg_if; +use leptos::*; +use leptos_router::*; +use serde::{Deserialize, Serialize}; + +cfg_if! { + if #[cfg(feature = "ssr")] { + use sqlx::{Connection, SqliteConnection}; + + 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 = use_context::>(cx) + // .expect("couldn't get HttpRequest from context"); + // println!("req.path = {:?}", req.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); + } + + 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) -> Element { + view! { + cx, +
+ +
+

"My Tasks"

+
+
+ + + }/> + +
+
+
+ } +} + +#[component] +pub fn Todos(cx: Scope) -> Element { + 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()}
}] + } + Ok(todos) => { + if todos.is_empty() { + vec![view! { cx,

"No tasks were found."

}] + } else { + todos + .into_iter() + .map({ + let delete_todo = delete_todo.clone(); + move |todo| { + let delete_todo = delete_todo.clone(); + view! { + cx, +
  • + {todo.title} + + + + +
  • + } + } + }) + .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}
      +
    + } + } + } +
    +
    +
    + } +} diff --git a/examples/todo-app-sqlite/Todos.db b/examples/todo-app-sqlite/Todos.db index 6df3c241029e0d6f7012e5fb0e49b340ee4b56d3..50f93b6b9bfaa2e86c765ee81548accf92bedc01 100644 GIT binary patch delta 49 zcmZo@U~Fh$oFL7}F;T{ukz-@R5`GQ_0R{&Cm;5g`3mQD&=Mop@u(!8oU|`_e{7s%q F0RTgF3|IgF delta 52 zcmZo@U~Fh$oFL7}Hc`fzk!@qb5`GRQ{+A5=pZH&H7Bsla&&$rl!XV6HZ*R}T$ilGs In>?2S0DCPBKmY&$ diff --git a/examples/todo-app-sqlite/src/main.rs b/examples/todo-app-sqlite/src/main.rs index e2dcee5..dbb4f12 100644 --- a/examples/todo-app-sqlite/src/main.rs +++ b/examples/todo-app-sqlite/src/main.rs @@ -33,7 +33,7 @@ cfg_if! { .route("/{tail:.*}", leptos_actix::render_app_to_stream("todo_app_sqlite", |cx| view! { cx, })) //.wrap(middleware::Compress::default()) }) - .bind(("127.0.0.1", 8081))? + .bind(("127.0.0.1", 8083))? .run() .await } diff --git a/integrations/axum/src/lib.rs b/integrations/axum/src/lib.rs index ea682c3..886906e 100644 --- a/integrations/axum/src/lib.rs +++ b/integrations/axum/src/lib.rs @@ -29,7 +29,7 @@ use std::{io, pin::Pin, sync::Arc}; /// /// // build our application with a route /// let app = Router::new() -/// .route("/api/tail*", post(leptos_axum::handle_server_fns)); +/// .route("/api/*fn_name", post(leptos_axum::handle_server_fns)); /// /// // run our app with hyper /// // `axum::Server` is a re-export of `hyper::Server` @@ -40,11 +40,14 @@ use std::{io, pin::Pin, sync::Arc}; /// } /// # } pub async fn handle_server_fns( - Path(path): Path, + Path(fn_name): Path, headers: HeaderMap, body: Bytes, - req: Request, + // req: Request, ) -> impl IntoResponse { + // Axum Path extractor doesn't remove the first slash from the path, while Actix does + let fn_name = fn_name.replace("/", ""); + let (tx, rx) = futures::channel::oneshot::channel(); std::thread::spawn({ move || { @@ -54,12 +57,12 @@ pub async fn handle_server_fns( async move { let body: &[u8] = &body; - let res = if let Some(server_fn) = server_fn_by_path(path.as_str()) { + let res = if let Some(server_fn) = server_fn_by_path(fn_name.as_str()) { let runtime = create_runtime(); let (cx, disposer) = raw_scope_and_disposer(runtime); // provide request as context in server scope - provide_context(cx, Arc::new(req)); + // provide_context(cx, Arc::new(req)); match server_fn(cx, body).await { Ok(serialized) => {