Mostly working version of axum with server functions

This commit is contained in:
Ben Wishovich
2022-11-27 16:55:38 -08:00
parent 36de06f183
commit 507191e1a4
13 changed files with 589 additions and 6 deletions

View File

@@ -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",

View File

@@ -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"]]

View File

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

View File

@@ -0,0 +1,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!

Binary file not shown.

View File

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

View File

@@ -0,0 +1,63 @@
use cfg_if::cfg_if;
cfg_if! {
if #[cfg(feature = "ssr")] {
use axum::{
body::{boxed, Body, BoxBody},
http::{Request, Response, StatusCode, Uri},
};
use tower::ServiceExt;
use tower_http::services::ServeDir;
pub async fn file_handler(uri: Uri) -> Result<Response<BoxBody>, (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<Response<BoxBody>, (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<Response<BoxBody>, (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()))
}
}
}
}

View File

@@ -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, <TodoApp/> }
});
}
}
}

View File

@@ -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, <TodoApp/> }
// };
// let head = r#"<!DOCTYPE html>
// <html lang="en">
// <head>
// <meta charset="utf-8"/>
// <meta name="viewport" content="width=device-width, initial-scale=1"/>
// <script type="module">import init, { hydrate } from '/pkg/todo_app_sqlite.js'; init().then(hydrate);</script>"#;
// let tail = "</body></html>";
// 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::<MetaContext>(cx)
// .map(|meta| meta.dehydrate())
// .unwrap_or_default();
// format!("{head}</head><body>{app}")
// }))
// .chain(futures::stream::once(async { tail.to_string() }))
// .map(|html| Ok(web::Bytes::from(html)) as Result<web::Bytes>),
// )
// }
// #[post("/api/{tail:.*}")]
// async fn handle_server_fns(
// req: HttpRequest,
// params: web::Path<String>,
// 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 <form> 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, <Todos/> }).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, <App/> }
});
}
}
}

View File

@@ -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<SqliteConnection, ServerFnError> {
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<Vec<Todo>, 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::<http::Request<axum::body::BoxBody>>(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,
<div>
<Router>
<header>
<h1>"My Tasks"</h1>
</header>
<main>
<Routes>
<Route path="" element=|cx| view! {
cx,
<Todos/>
}/>
</Routes>
</main>
</Router>
</div>
}
}
#[component]
pub fn Todos(cx: Scope) -> Element {
let add_todo = create_server_multi_action::<AddTodo>(cx);
let delete_todo = create_server_action::<DeleteTodo>(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,
<div>
<MultiActionForm action=add_todo>
<label>
"Add a Todo"
<input type="text" name="title"/>
</label>
<input type="submit" value="Add"/>
</MultiActionForm>
<div>
<Suspense fallback=view! {cx, <p>"Loading..."</p> }>
{
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, <pre class="error">"Server Error: " {e.to_string()}</pre>}]
}
Ok(todos) => {
if todos.is_empty() {
vec![view! { cx, <p>"No tasks were found."</p> }]
} else {
todos
.into_iter()
.map({
let delete_todo = delete_todo.clone();
move |todo| {
let delete_todo = delete_todo.clone();
view! {
cx,
<li>
{todo.title}
<ActionForm action=delete_todo.clone()>
<input type="hidden" name="id" value={todo.id}/>
<input type="submit" value="X"/>
</ActionForm>
</li>
}
}
})
.collect::<Vec<_>>()
}
}
}
})
.unwrap_or_default()
}
};
let pending_todos = move || {
submissions
.get()
.into_iter()
.filter(|submission| submission.pending().get())
.map(|submission| {
view! {
cx,
<li class="pending">{move || submission.input.get().map(|data| data.title) }</li>
}
})
.collect::<Vec<_>>()
};
view! {
cx,
<ul>
<div>{existing_todos}</div>
<div>{pending_todos}</div>
</ul>
}
}
}
</Suspense>
</div>
</div>
}
}

Binary file not shown.

View File

@@ -33,7 +33,7 @@ cfg_if! {
.route("/{tail:.*}", leptos_actix::render_app_to_stream("todo_app_sqlite", |cx| view! { cx, <TodoApp/> }))
//.wrap(middleware::Compress::default())
})
.bind(("127.0.0.1", 8081))?
.bind(("127.0.0.1", 8083))?
.run()
.await
}

View File

@@ -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<String>,
Path(fn_name): Path<String>,
headers: HeaderMap<HeaderValue>,
body: Bytes,
req: Request<Body>,
// req: Request<Body>,
) -> 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) => {