examples: add session_auth_axum (#589)

This commit is contained in:
jo!
2023-03-07 02:16:56 +00:00
committed by GitHub
parent 9cc28943aa
commit 369ea8531f
19 changed files with 1202 additions and 0 deletions

View File

@@ -39,6 +39,7 @@ dependencies = [
{ name = "check", path = "examples/login_with_token_csr_only" },
{ name = "check", path = "examples/parent_child" },
{ name = "check", path = "examples/router" },
{ name = "check", path = "examples/session_auth_axum" },
{ name = "check", path = "examples/ssr_modes" },
{ name = "check", path = "examples/ssr_modes_axum" },
{ name = "check", path = "examples/tailwind" },

View File

@@ -0,0 +1 @@
use flake

View File

@@ -0,0 +1,113 @@
[package]
name = "session_auth_axum"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
anyhow = "1.0.66"
console_log = "0.2.0"
rand = { version = "0.8.5", features = ["min_const_gen"], optional = true }
console_error_panic_hook = "0.1.7"
futures = "0.3.25"
cfg-if = "1.0.0"
leptos = { version = "0.2.0", default-features = false, features = [
"serde",
] }
leptos_meta = { version = "0.2.0", default-features = false }
leptos_axum = { version = "0.2.0", optional = true }
leptos_router = { version = "0.2.0", default-features = false }
leptos_reactive = { version = "0.2.0", 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 }
thiserror = "1.0.38"
tracing = "0.1.37"
wasm-bindgen = "0.2"
axum_sessions_auth = { version = "7.0.0", features = [ "sqlite-rustls" ], optional = true }
axum_database_sessions = { version = "7.0.0", features = [ "sqlite-rustls" ], optional = true }
bcrypt = { version = "0.14", optional = true }
async-trait = {version = "0.1.64", 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:axum_sessions_auth",
"dep:axum_database_sessions",
"dep:async-trait",
"dep:sqlx",
"dep:bcrypt",
"dep:rand",
"leptos/ssr",
"leptos_meta/ssr",
"leptos_router/ssr",
"dep: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"]]
[package.metadata.leptos]
# The name used by wasm-bindgen/cargo-leptos for the JS/WASM bundle. Defaults to the crate name
output-name = "session_auth_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 <site-root>/<site-pkg>/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 = "npx playwright test"
# 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

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,9 @@
[tasks.build]
command = "cargo"
args = ["+nightly", "build-all-features"]
install_crate = "cargo-all-features"
[tasks.check]
command = "cargo"
args = ["+nightly", "check-all-features"]
install_crate = "cargo-all-features"

View File

@@ -0,0 +1,42 @@
# Leptos Authenticated 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. It lets you login, signup, and submit todos as different users, or a guest.
## Client Side Rendering
This example cannot be built as a trunk standalone CSR-only app. Only the server may directly connect to the database.
## Server Side Rendering with cargo-leptos
cargo-leptos is now the easiest and most featureful way to build server side rendered apps with hydration. It provides automatic recompilation of client and server code, wasm optimisation, CSS minification, and more! Check out more about it [here](https://github.com/akesson/cargo-leptos)
1. Install cargo-leptos
```bash
cargo install --locked cargo-leptos
```
2. Build the site in watch mode, recompiling on file changes
```bash
cargo leptos watch
```
Open browser on [http://localhost:3000/](http://localhost:3000/)
3. When ready to deploy, run
```bash
cargo leptos build --release
```
## Server Side Rendering without cargo-leptos
To run it as a server side app with hydration, you'll need to have wasm-pack installed.
0. Edit the `[package.metadata.leptos]` section and set `site-root` to `"."`. You'll also want to change the path of the `<StyleSheet / >` component in the root component to point towards the CSS file in the root. This tells leptos that the WASM/JS files generated by wasm-pack are available at `./pkg` and that the CSS files are no longer processed by cargo-leptos. Building to alternative folders is not supported at this time. You'll also want to edit the call to `get_configuration()` to pass in `Some(Cargo.toml)`, so that Leptos will read the settings instead of cargo-leptos. If you do so, your file/folder names cannot include dashes.
1. Install wasm-pack
```bash
cargo install wasm-pack
```
2. Build the Webassembly used to hydrate the HTML from the server
```bash
wasm-pack build --target=web --debug --no-default-features --features=hydrate
```
3. Run the server to serve the Webassembly, JS, and HTML
```bash
cargo run --no-default-features --features=ssr
```

Binary file not shown.

80
examples/session_auth_axum/flake.lock generated Normal file
View File

@@ -0,0 +1,80 @@
{
"nodes": {
"flake-utils": {
"locked": {
"lastModified": 1676283394,
"narHash": "sha256-XX2f9c3iySLCw54rJ/CZs+ZK6IQy7GXNY4nSOyu2QG4=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "3db36a8b464d0c4532ba1c7dda728f4576d6d073",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"flake-utils_2": {
"locked": {
"lastModified": 1659877975,
"narHash": "sha256-zllb8aq3YO3h8B/U0/J1WBgAL8EX5yWf5pMj3G0NAmc=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "c0e246b9b83f637f4681389ecabcb2681b4f3af0",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1672580127,
"narHash": "sha256-3lW3xZslREhJogoOkjeZtlBtvFMyxHku7I/9IVehhT8=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "0874168639713f547c05947c76124f78441ea46c",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-22.05",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs",
"rust-overlay": "rust-overlay"
}
},
"rust-overlay": {
"inputs": {
"flake-utils": "flake-utils_2",
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1677292251,
"narHash": "sha256-D+6q5Z2MQn3UFJtqsM5/AvVHi3NXKZTIMZt1JGq/spA=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "34cdbf6ad480ce13a6a526f57d8b9e609f3d65dc",
"type": "github"
},
"original": {
"owner": "oxalica",
"repo": "rust-overlay",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

View File

@@ -0,0 +1,37 @@
{
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-22.05";
inputs.rust-overlay.url = "github:oxalica/rust-overlay";
inputs.rust-overlay.inputs.nixpkgs.follows = "nixpkgs";
inputs.flake-utils.url = "github:numtide/flake-utils";
outputs = { self, nixpkgs, rust-overlay, flake-utils }:
flake-utils.lib.eachDefaultSystem (system:
let
pkgs = import nixpkgs {
inherit system;
overlays = [ (import rust-overlay) ];
};
in
with pkgs; rec {
devShells.default = mkShell {
shellHook = ''
export PKG_CONFIG_PATH="${pkgs.openssl.dev}/lib/pkgconfig";
'';
nativeBuildInputs = [
pkg-config
];
buildInputs = [
trunk
sqlite
sass
openssl
(rust-bin.nightly.latest.default.override {
extensions = [ "rust-src" ];
targets = [ "wasm32-unknown-unknown" ];
})
];
};
});
}

View File

@@ -0,0 +1,27 @@
CREATE TABLE IF NOT EXISTS users (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS user_permissions (
user_id INTEGER NOT NULL,
token TEXT NOT NULL
);
-- INSERT INTO users (id, anonymous, username, password)
-- SELECT 0, true, 'Guest', ''
-- ON CONFLICT(id) DO UPDATE SET
-- anonymous = EXCLUDED.anonymous,
-- username = EXCLUDED.username;
CREATE TABLE IF NOT EXISTS todos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
title TEXT NOT NULL,
completed BOOLEAN,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
-- FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1,231 @@
use std::collections::HashSet;
use cfg_if::cfg_if;
use leptos::*;
use serde::{Deserialize, Serialize};
cfg_if! {
if #[cfg(feature = "ssr")] {
use sqlx::SqlitePool;
use axum_sessions_auth::{SessionSqlitePool, Authentication, HasPermission};
use bcrypt::{hash, verify, DEFAULT_COST};
use crate::todo::{pool, auth};
pub type AuthSession = axum_sessions_auth::AuthSession<User, i64, SessionSqlitePool, SqlitePool>;
}}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct User {
pub id: i64,
pub username: String,
pub password: String,
pub permissions: HashSet<String>,
}
impl Default for User {
fn default() -> Self {
let permissions = HashSet::new();
Self {
id: -1,
username: "Guest".into(),
password: "".into(),
permissions,
}
}
}
cfg_if! {
if #[cfg(feature = "ssr")] {
use async_trait::async_trait;
impl User {
pub async fn get(id: i64, pool: &SqlitePool) -> Option<Self> {
let sqluser = sqlx::query_as::<_, SqlUser>("SELECT * FROM users WHERE id = ?")
.bind(id)
.fetch_one(pool)
.await
.ok()?;
//lets just get all the tokens the user can use, we will only use the full permissions if modifing them.
let sql_user_perms = sqlx::query_as::<_, SqlPermissionTokens>(
"SELECT token FROM user_permissions WHERE user_id = ?;",
)
.bind(id)
.fetch_all(pool)
.await
.ok()?;
Some(sqluser.into_user(Some(sql_user_perms)))
}
pub async fn get_from_username(name: String, pool: &SqlitePool) -> Option<Self> {
let sqluser = sqlx::query_as::<_, SqlUser>("SELECT * FROM users WHERE username = ?")
.bind(name)
.fetch_one(pool)
.await
.ok()?;
//lets just get all the tokens the user can use, we will only use the full permissions if modifing them.
let sql_user_perms = sqlx::query_as::<_, SqlPermissionTokens>(
"SELECT token FROM user_permissions WHERE user_id = ?;",
)
.bind(sqluser.id)
.fetch_all(pool)
.await
.ok()?;
Some(sqluser.into_user(Some(sql_user_perms)))
}
}
#[derive(sqlx::FromRow, Clone)]
pub struct SqlPermissionTokens {
pub token: String,
}
#[async_trait]
impl Authentication<User, i64, SqlitePool> for User {
async fn load_user(userid: i64, pool: Option<&SqlitePool>) -> Result<User, anyhow::Error> {
let pool = pool.unwrap();
User::get(userid, pool)
.await
.ok_or_else(|| anyhow::anyhow!("Cannot get user"))
}
fn is_authenticated(&self) -> bool {
true
}
fn is_active(&self) -> bool {
true
}
fn is_anonymous(&self) -> bool {
false
}
}
#[async_trait]
impl HasPermission<SqlitePool> for User {
async fn has(&self, perm: &str, _pool: &Option<&SqlitePool>) -> bool {
self.permissions.contains(perm)
}
}
#[derive(sqlx::FromRow, Clone)]
pub struct SqlUser {
pub id: i64,
pub username: String,
pub password: String,
}
impl SqlUser {
pub fn into_user(self, sql_user_perms: Option<Vec<SqlPermissionTokens>>) -> User {
User {
id: self.id,
username: self.username,
password: self.password,
permissions: if let Some(user_perms) = sql_user_perms {
user_perms
.into_iter()
.map(|x| x.token)
.collect::<HashSet<String>>()
} else {
HashSet::<String>::new()
},
}
}
}
}
}
#[server(Foo, "/api")]
pub async fn foo() -> Result<String, ServerFnError> {
Ok(String::from("Bar!"))
}
#[server(GetUser, "/api")]
pub async fn get_user(cx: Scope) -> Result<Option<User>, ServerFnError> {
let auth = auth(cx)?;
Ok(auth.current_user)
}
#[server(Login, "/api")]
pub async fn login(
cx: Scope,
username: String,
password: String,
remember: Option<String>,
) -> Result<(), ServerFnError> {
let pool = pool(cx)?;
let auth = auth(cx)?;
let user: User = User::get_from_username(username, &pool)
.await
.ok_or("User does not exist.")
.map_err(|e| ServerFnError::ServerError(e.to_string()))?;
match verify(password, &user.password).map_err(|e| ServerFnError::ServerError(e.to_string()))? {
true => {
auth.login_user(user.id);
auth.remember_user(remember.is_some());
leptos_axum::redirect(cx, "/");
Ok(())
}
false => Err(ServerFnError::ServerError(
"Password does not match.".to_string(),
)),
}
}
#[server(Signup, "/api")]
pub async fn signup(
cx: Scope,
username: String,
password: String,
password_confirmation: String,
remember: Option<String>,
) -> Result<(), ServerFnError> {
let pool = pool(cx)?;
let auth = auth(cx)?;
if password != password_confirmation {
return Err(ServerFnError::ServerError(
"Passwords did not match.".to_string(),
));
}
let password_hashed = hash(password, DEFAULT_COST).unwrap();
sqlx::query("INSERT INTO users (username, password) VALUES (?,?)")
.bind(username.clone())
.bind(password_hashed)
.execute(&pool)
.await
.map_err(|e| ServerFnError::ServerError(e.to_string()))?;
let user = User::get_from_username(username, &pool)
.await
.ok_or("Signup failed: User does not exist.")
.map_err(|e| ServerFnError::ServerError(e.to_string()))?;
auth.login_user(user.id);
auth.remember_user(remember.is_some());
leptos_axum::redirect(cx, "/");
Ok(())
}
#[server(Logout, "/api")]
pub async fn logout(cx: Scope) -> Result<(), ServerFnError> {
let auth = auth(cx)?;
auth.logout_user();
leptos_axum::redirect(cx, "/");
Ok(())
}

View File

@@ -0,0 +1,61 @@
use crate::errors::TodoAppError;
use cfg_if::cfg_if;
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(
cx: Scope,
#[prop(optional)] outside_errors: Option<Errors>,
#[prop(optional)] errors: Option<RwSignal<Errors>>,
) -> impl IntoView {
let errors = match outside_errors {
Some(e) => create_rw_signal(cx, 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<TodoAppError> = errors
.get()
.into_iter()
.filter_map(|(_, v)| v.downcast_ref::<TodoAppError>().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_if! {
if #[cfg(feature="ssr")]{
let response = use_context::<ResponseOptions>(cx);
if let Some(response) = response{
response.set_status(errors[0].status_code());
}
}
}
view! {cx,
<h1>"Errors"</h1>
<For
// a function that returns the items we're iterating over; a signal is fine
each= move || {errors.clone().into_iter().enumerate()}
// a unique key for each item as a reference
key=|(index, _error)| *index
// renders each item to a view
view= move |cx, error| {
let error_string = error.1.to_string();
let error_code= error.1.status_code();
view! {
cx,
<h2>{error_code.to_string()}</h2>
<p>"Error: " {error_string}</p>
}
}
/>
}
}

View File

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

View File

@@ -0,0 +1,49 @@
use cfg_if::cfg_if;
cfg_if! {
if #[cfg(feature = "ssr")] {
use axum::{
body::{boxed, Body, BoxBody},
extract::Extension,
response::IntoResponse,
http::{Request, Response, StatusCode, Uri},
};
use axum::response::Response as AxumResponse;
use tower::ServiceExt;
use tower_http::services::ServeDir;
use std::sync::Arc;
use leptos::{LeptosOptions, Errors, view};
use crate::error_template::{ErrorTemplate, ErrorTemplateProps};
use crate::errors::TodoAppError;
pub async fn file_and_error_handler(uri: Uri, Extension(options): Extension<Arc<LeptosOptions>>, req: Request<Body>) -> AxumResponse {
let options = &*options;
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 |cx| view!{cx, <ErrorTemplate outside_errors=errors.clone()/>});
handler(req).await.into_response()
}
}
async fn get_static_file(uri: Uri, root: &str) -> Result<Response<BoxBody>, (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.map(boxed)),
Err(err) => Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("Something went wrong: {err}"),
)),
}
}
}
}

View File

@@ -0,0 +1,27 @@
use cfg_if::cfg_if;
pub mod auth;
pub mod error_template;
pub mod errors;
pub mod fallback;
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::*;
use leptos::view;
#[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, <TodoApp/> }
});
}
}
}

View File

@@ -0,0 +1,102 @@
use cfg_if::cfg_if;
// boilerplate to run in different modes
cfg_if! {
if #[cfg(feature = "ssr")] {
use axum::{
response::{Response, IntoResponse},
routing::{post, get},
extract::{Path, Extension},
http::{Request, header::HeaderMap},
body::Body as AxumBody,
Router,
};
use session_auth_axum::todo::*;
use session_auth_axum::auth::*;
use session_auth_axum::*;
use session_auth_axum::fallback::file_and_error_handler;
use leptos_axum::{generate_route_list, LeptosRoutes, handle_server_fns_with_context};
use leptos::{log, view, provide_context, LeptosOptions, get_configuration, ServerFnError};
use std::sync::Arc;
use sqlx::{SqlitePool, sqlite::SqlitePoolOptions};
use axum_database_sessions::{SessionConfig, SessionLayer, SessionStore};
use axum_sessions_auth::{AuthSessionLayer, AuthConfig, SessionSqlitePool};
async fn server_fn_handler(Extension(pool): Extension<SqlitePool>, auth_session: AuthSession, path: Path<String>, headers: HeaderMap, request: Request<AxumBody>) -> impl IntoResponse {
log!("{:?}", path);
handle_server_fns_with_context(path, headers, move |cx| {
provide_context(cx, auth_session.clone());
provide_context(cx, pool.clone());
}, request).await
}
async fn leptos_routes_handler(Extension(pool): Extension<SqlitePool>, auth_session: AuthSession, Extension(options): Extension<Arc<LeptosOptions>>, req: Request<AxumBody>) -> Response{
let handler = leptos_axum::render_app_to_stream_with_context((*options).clone(),
move |cx| {
provide_context(cx, auth_session.clone());
provide_context(cx, pool.clone());
},
|cx| view! { cx, <TodoApp/> }
);
handler(req).await.into_response()
}
#[tokio::main]
async fn main() {
simple_logger::init_with_level(log::Level::Info).expect("couldn't initialize logging");
let pool = SqlitePoolOptions::new()
.connect("sqlite:Todos.db")
.await
.expect("Could not make pool.");
// Auth section
let session_config = SessionConfig::default().with_table_name("axum_sessions");
let auth_config = AuthConfig::<i64>::default();
let session_store = SessionStore::<SessionSqlitePool>::new(Some(pool.clone().into()), session_config);
session_store.initiate().await.unwrap();
sqlx::migrate!()
.run(&pool)
.await
.expect("could not run SQLx migrations");
crate::todo::register_server_functions();
// 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(|cx| view! { cx, <TodoApp/> }).await;
// build our application with a route
let app = Router::new()
.route("/api/*fn_name", post(server_fn_handler))
.leptos_routes_with_handler(routes, get(leptos_routes_handler) )
.fallback(file_and_error_handler)
.layer(AuthSessionLayer::<User, i64, SessionSqlitePool, SqlitePool>::new(Some(pool.clone()))
.with_config(auth_config))
.layer(SessionLayer::new(session_store))
.layer(Extension(Arc::new(leptos_options)))
.layer(Extension(pool));
// run our app with hyper
// `axum::Server` is a re-export of `hyper::Server`
log!("listening on http://{}", &addr);
axum::Server::bind(&addr)
.serve(app.into_make_service())
.await
.unwrap();
}
}
// client-only stuff for Trunk
else {
pub fn main() {
// This example cannot be built as a trunk standalone CSR-only app.
// Only the server may directly connect to the database.
}
}
}

View File

@@ -0,0 +1,375 @@
use crate::auth::*;
use crate::error_template::{ErrorTemplate, ErrorTemplateProps};
use cfg_if::cfg_if;
use leptos::*;
use leptos_meta::*;
use leptos_router::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Todo {
id: u32,
user: Option<User>,
title: String,
created_at: String,
completed: bool,
}
cfg_if! {
if #[cfg(feature = "ssr")] {
use sqlx::SqlitePool;
pub fn pool(cx: Scope) -> Result<SqlitePool, ServerFnError> {
Ok(use_context::<SqlitePool>(cx)
.ok_or("Pool missing.")
.map_err(|e| ServerFnError::ServerError(e.to_string()))?)
}
pub fn auth(cx: Scope) -> Result<AuthSession, ServerFnError> {
Ok(use_context::<AuthSession>(cx)
.ok_or("Auth session missing.")
.map_err(|e| ServerFnError::ServerError(e.to_string()))?)
}
pub fn register_server_functions() {
_ = GetTodos::register();
_ = AddTodo::register();
_ = DeleteTodo::register();
_ = Login::register();
_ = Logout::register();
_ = Signup::register();
_ = GetUser::register();
_ = Foo::register();
}
#[derive(sqlx::FromRow, Clone)]
pub struct SqlTodo {
id: u32,
user_id: i64,
title: String,
created_at: String,
completed: bool,
}
impl SqlTodo {
pub async fn into_todo(self, pool: &SqlitePool) -> Todo {
Todo {
id: self.id,
user: User::get(self.user_id, pool).await,
title: self.title,
created_at: self.created_at,
completed: self.completed,
}
}
}
}
}
#[server(GetTodos, "/api")]
pub async fn get_todos(cx: Scope) -> Result<Vec<Todo>, ServerFnError> {
use futures::TryStreamExt;
let pool = pool(cx)?;
let mut todos = Vec::new();
let mut rows = sqlx::query_as::<_, SqlTodo>("SELECT * FROM todos").fetch(&pool);
while let Some(row) = rows
.try_next()
.await
.map_err(|e| ServerFnError::ServerError(e.to_string()))?
{
todos.push(row);
}
// why can't we just have async closures?
// let mut rows: Vec<Todo> = rows.iter().map(|t| async { t }).collect();
let mut converted_todos = Vec::with_capacity(todos.len());
for t in todos {
let todo = t.into_todo(&pool).await;
converted_todos.push(todo);
}
let todos: Vec<Todo> = converted_todos;
Ok(todos)
}
#[server(AddTodo, "/api")]
pub async fn add_todo(cx: Scope, title: String) -> Result<(), ServerFnError> {
let user = get_user(cx).await?;
let pool = pool(cx)?;
let id = match user {
Some(user) => user.id,
None => -1,
};
// fake API delay
std::thread::sleep(std::time::Duration::from_millis(1250));
match sqlx::query("INSERT INTO todos (title, user_id, completed) VALUES (?, ?, false)")
.bind(title)
.bind(id)
.execute(&pool)
.await
{
Ok(_row) => Ok(()),
Err(e) => Err(ServerFnError::ServerError(e.to_string())),
}
}
#[server(DeleteTodo, "/api")]
pub async fn delete_todo(cx: Scope, id: u16) -> Result<(), ServerFnError> {
let pool = pool(cx)?;
sqlx::query("DELETE FROM todos WHERE id = $1")
.bind(id)
.execute(&pool)
.await
.map(|_| ())
.map_err(|e| ServerFnError::ServerError(e.to_string()))
}
#[component]
pub fn TodoApp(cx: Scope) -> impl IntoView {
let login = create_server_action::<Login>(cx);
let logout = create_server_action::<Logout>(cx);
let signup = create_server_action::<Signup>(cx);
let user = create_resource(
cx,
move || {
(
login.version().get(),
signup.version().get(),
logout.version().get(),
)
},
move |_| get_user(cx),
);
provide_meta_context(cx);
view! {
cx,
<Link rel="shortcut icon" type_="image/ico" href="/favicon.ico"/>
<Stylesheet id="leptos" href="/pkg/session_auth_axum.css"/>
<Router>
<header>
<A href="/"><h1>"My Tasks"</h1></A>
<Transition
fallback=move || view! {cx, <span>"Loading..."</span>}
>
{move || {
user.read(cx).map(|user| match user {
Err(e) => view! {cx,
<A href="/signup">"Signup"</A>", "
<A href="/login">"Login"</A>", "
<span>{format!("Login error: {}", e.to_string())}</span>
}.into_view(cx),
Ok(None) => view! {cx,
<A href="/signup">"Signup"</A>", "
<A href="/login">"Login"</A>", "
<span>"Logged out."</span>
}.into_view(cx),
Ok(Some(user)) => view! {cx,
<A href="/settings">"Settings"</A>", "
<span>{format!("Logged in as: {} ({})", user.username, user.id)}</span>
}.into_view(cx)
})
}}
</Transition>
</header>
<hr/>
<main>
<Routes>
<Route path="" view=|cx| view! {
cx,
<ErrorBoundary fallback=|cx, errors| view!{cx, <ErrorTemplate errors=errors/>}>
<Todos/>
</ErrorBoundary>
}/> //Route
<Route path="signup" view=move |cx| view! {
cx,
<Signup action=signup/>
}/>
<Route path="login" view=move |cx| view! {
cx,
<Login action=login />
}/>
<Route path="settings" view=move |cx| view! {
cx,
<h1>"Settings"</h1>
<Logout action=logout />
}/>
</Routes>
</main>
</Router>
}
}
#[component]
pub fn Todos(cx: Scope) -> impl IntoView {
let add_todo = create_server_multi_action::<AddTodo>(cx);
let delete_todo = create_server_action::<DeleteTodo>(cx);
let submissions = add_todo.submissions();
// list of todos is loaded from the server in reaction to changes
let todos = create_resource(
cx,
move || (add_todo.version().get(), delete_todo.version().get()),
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>
<Transition fallback=move || view! {cx, <p>"Loading..."</p> }>
{move || {
let existing_todos = {
move || {
todos.read(cx)
.map(move |todos| match todos {
Err(e) => {
vec![view! { cx, <pre class="error">"Server Error: " {e.to_string()}</pre>}.into_any()]
}
Ok(todos) => {
if todos.is_empty() {
vec![view! { cx, <p>"No tasks were found."</p> }.into_any()]
} else {
todos
.into_iter()
.map(move |todo| {
view! {
cx,
<li>
{todo.title}
": Created at "
{todo.created_at}
" by "
{
todo.user.unwrap_or_default().username
}
<ActionForm action=delete_todo>
<input type="hidden" name="id" value={todo.id}/>
<input type="submit" value="X"/>
</ActionForm>
</li>
}
.into_any()
})
.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>
{existing_todos}
{pending_todos}
</ul>
}
}
}
</Transition>
</div>
}
}
#[component]
pub fn Login(cx: Scope, action: Action<Login, Result<(), ServerFnError>>) -> impl IntoView {
view! {
cx,
<ActionForm action=action>
<h1>"Log In"</h1>
<label>
"User ID:"
<input type="text" placeholder="User ID" maxlength="32" name="username" class="auth-input" />
</label>
<br/>
<label>
"Password:"
<input type="password" placeholder="Password" name="password" class="auth-input" />
</label>
<br/>
<label>
<input type="checkbox" name="remember" class="auth-input" />
"Remember me?"
</label>
<br/>
<button type="submit" class="button">"Log In"</button>
</ActionForm>
}
}
#[component]
pub fn Signup(cx: Scope, action: Action<Signup, Result<(), ServerFnError>>) -> impl IntoView {
view! {
cx,
<ActionForm action=action>
<h1>"Sign Up"</h1>
<label>
"User ID:"
<input type="text" placeholder="User ID" maxlength="32" name="username" class="auth-input" />
</label>
<br/>
<label>
"Password:"
<input type="password" placeholder="Password" name="password" class="auth-input" />
</label>
<br/>
<label>
"Confirm Password:"
<input type="password" placeholder="Password again" name="password_confirmation" class="auth-input" />
</label>
<br/>
<label>
"Remember me?"
<input type="checkbox" name="remember" class="auth-input" />
</label>
<br/>
<button type="submit" class="button">"Sign Up"</button>
</ActionForm>
}
}
#[component]
pub fn Logout(cx: Scope, action: Action<Logout, Result<(), ServerFnError>>) -> impl IntoView {
view! {
cx,
<div id="loginbox">
<ActionForm action=action>
<button type="submit" class="button">"Log Out"</button>
</ActionForm>
</div>
}
}

View File

@@ -0,0 +1,7 @@
.pending {
color: purple;
}
a {
color: black;
}