Compare commits

...

84 Commits
4277 ... pavex

Author SHA1 Message Date
Greg Johnston
ed86c72598 add missing PartialEq/Eq implementations on ServerFnError (closes #2198) 2024-01-19 12:46:08 -05:00
Greg Johnston
eb87fb6d70 clean up docs (closes #2197) 2024-01-19 12:43:56 -05:00
Greg Johnston
0ba713b0d6 Merge branch 'main' into leptos_v0.6 2024-01-19 12:18:09 -05:00
Greg Johnston
e9f7af4b72 use &[u8] instead of Bytes for requests 2024-01-19 12:08:27 -05:00
Greg Johnston
62298dbba2 share inventory collect across types 2024-01-19 11:39:51 -05:00
Greg Johnston
9855bc5165 expose all fields of ServerFnTraitObj via methods 2024-01-19 11:30:28 -05:00
Greg Johnston
fb7bd2506b weak dependency on Cargo.toml 2024-01-19 11:07:17 -05:00
Greg Johnston
8d8e9b4129 fix warning 2024-01-19 11:07:08 -05:00
Greg Johnston
ce86979d70 serde-lite support should be enabled directly on server_fn 2024-01-17 21:27:44 -05:00
Greg Johnston
e7cf7cc972 partial support for streaming requests (doesn't actually work in the browser) 2024-01-17 21:26:36 -05:00
Greg Johnston
4228ebaed3 remove misleading warning 2024-01-17 19:30:48 -05:00
Greg Johnston
a3142267a9 example of middleware that can run before and/or after server fn 2024-01-17 10:29:45 -05:00
Greg Johnston
423d0b8e2a fix version numbers 2024-01-17 07:57:04 -05:00
Greg Johnston
f934d59821 add package metadata 2024-01-17 07:55:23 -05:00
Greg Johnston
b7483d74bf fix merge error 2024-01-17 07:49:21 -05:00
Greg Johnston
8b5dd99d7f clippy 2024-01-17 07:48:30 -05:00
Greg Johnston
22e6a13ca8 update version number 2024-01-16 20:07:44 -05:00
Greg Johnston
bc2378e4f7 erroneous hyphen 2024-01-16 20:06:10 -05:00
Greg Johnston
1af8214b1f Merge branch 'main' into leptos_v0.6 2024-01-16 20:06:02 -05:00
Greg Johnston
6e52401e22 streaming example with filesystem watcher 2024-01-16 20:01:26 -05:00
Greg Johnston
47ac5adae9 add streaming/file watcher example 2024-01-16 13:08:46 -05:00
Greg Johnston
f5b22f27fc add middleware to kitchen-sink example 2024-01-15 21:02:52 -05:00
Greg Johnston
7c9ee2dbb4 example with custom errors 2024-01-15 20:38:21 -05:00
Greg Johnston
f89ba9bcb3 file upload example 2024-01-15 17:07:18 -05:00
Greg Johnston
1fb7874579 hm custom encodings have orphan rule issues 2024-01-15 17:07:18 -05:00
Greg Johnston
a8ce7d3e6e get rkyv working and work on custom encoding example 2024-01-15 17:07:18 -05:00
Markus Kohlhase
862390ad97 Update login example (CSR only) (#2155) 2024-01-15 12:42:04 -08:00
Ari Seyhun
4616feea30 fix!: remove clone in Cow<'static, str> IntoView impl (#1946) 2024-01-15 10:52:58 -08:00
Greg Johnston
d8cbda5f6f working on example 2024-01-14 20:20:09 -05:00
Greg Johnston
3b6d5a3bdd feature-gate the form redirect stuff, and clear old errors from query 2024-01-14 20:20:09 -05:00
Greg Johnston
9dc97836b8 working on Axum version 2024-01-14 20:20:09 -05:00
Greg Johnston
d87997955d working on server fn example 2024-01-14 20:20:09 -05:00
Greg Johnston
8257430d2d Merge branch 'main' into leptos_v0.6 2024-01-13 21:59:40 -05:00
Greg Johnston
50bec1c5f8 generalize error redirect behavior across integrations 2024-01-13 21:58:46 -05:00
Greg Johnston
d7755eb5cc support setting server URL on either platform 2024-01-13 15:17:23 -05:00
Greg Johnston
30f3d67aec get both client and server side working 2024-01-12 20:40:03 -05:00
Greg Johnston
31cee81a39 initial version of server action error handling without JS 2024-01-12 20:23:26 -05:00
Greg Johnston
7a0d67932e docs 2024-01-12 12:50:32 -05:00
Greg Johnston
d5aecdaa43 remove old code 2024-01-12 12:40:19 -05:00
Greg Johnston
3d2626754c docs 2024-01-12 12:40:08 -05:00
Greg Johnston
2120174c9f more docs 2024-01-11 22:09:40 -05:00
Greg Johnston
013f5dbb56 getting started on docs 2024-01-11 17:26:08 -05:00
Greg Johnston
b8fdcadc2f remove cfg-if from all examples 2024-01-10 21:43:19 -05:00
Greg Johnston
3124c74060 remove explicit handle_server_fns in most cases because it's now included in .leptos_routes() 2024-01-10 20:35:45 -05:00
Greg Johnston
2f81e206e3 nicer formatting, remove cfg-if 2024-01-10 20:31:30 -05:00
Greg Johnston
45dfb96bbe remove viz integration (see #2177) 2024-01-10 20:15:30 -05:00
Greg Johnston
ef39eb5e4b smh 2024-01-10 14:28:14 -05:00
Greg Johnston
90f1bd9bfc missing makefiles 2024-01-10 14:08:51 -05:00
Greg Johnston
7f505755de update session_auth_axum 2024-01-10 14:05:24 -05:00
Greg Johnston
29db073e22 update todo_app_sqlite_csrs 2024-01-10 12:47:09 -05:00
Greg Johnston
bbb8672927 clippy 2024-01-10 08:57:11 -05:00
Greg Johnston
d4bea99f11 allow type paths for input/output, and properly namespace built-in encodings 2024-01-10 08:04:23 -05:00
Greg Johnston
25832c1388 chore: clear warnings 2024-01-10 08:04:23 -05:00
Greg Johnston
73f8f893e2 remove list of magic identifiers, use rust-analyzer to help with imports instead 2024-01-10 08:04:23 -05:00
Greg Johnston
ed1555f634 use server fns directly in ActionForm and MultiActionForm 2024-01-10 08:04:23 -05:00
Rakshith Ravi
1cf78a4515 feat: add serde-lite codec for server functions (#2168) 2024-01-10 08:03:52 -05:00
Rakshith Ravi
ccd88d4932 Fixed tests for server_fn (#2167)
* Fixed server_fn tests

* Changed type_name to TypeId

* Fixed handling of leading slashes for server_fn endpoint
2024-01-10 08:03:52 -05:00
Greg Johnston
ffa99a0b01 add missing server fn registration 2024-01-10 08:03:52 -05:00
Greg Johnston
2e61b2f241 make sure endpoint names begin with a / 2024-01-10 08:03:52 -05:00
Greg Johnston
bde80d0d94 set version, input, etc. correctly 2024-01-10 08:03:52 -05:00
Greg Johnston
63cce54cda remove unused var 2024-01-10 08:03:52 -05:00
Greg Johnston
ca84fa6e7a set up redirects in Actix 2024-01-10 08:03:52 -05:00
Greg Johnston
badcc9669a handle client-side and server-side redirects correctly (in Axum) 2024-01-10 08:03:52 -05:00
Greg Johnston
8317816f64 actually use server functions in ActionForm 2024-01-10 08:03:52 -05:00
Greg Johnston
e4eb995d9a Restore the previous full functionality of Form 2024-01-10 08:03:52 -05:00
Greg Johnston
0ee50ecfd5 finished Actix support? 2024-01-10 08:03:52 -05:00
Greg Johnston
feaa2adccf more Actix work 2024-01-10 08:03:52 -05:00
Greg Johnston
35f7cba1bc start Actix work 2024-01-10 08:03:52 -05:00
Greg Johnston
a34b13572d clear up warnings 2024-01-10 08:03:52 -05:00
Greg Johnston
87cd836934 automatically include server function handler in .leptos_router() 2024-01-10 08:03:52 -05:00
Greg Johnston
07f5e06850 changes to get todo_app_sqlite_axum example working 2024-01-10 08:03:52 -05:00
Greg Johnston
25f9d34d13 fix server actions and server multi actions 2024-01-10 08:03:52 -05:00
Greg Johnston
f02eaaf672 cargo fmt 2024-01-10 08:03:52 -05:00
Greg Johnston
82a7dfaadb @ealmloff changes to reexport actix/axum 2024-01-10 08:03:52 -05:00
Greg Johnston
e643cd941c fix Actix implementation with middleware 2024-01-10 08:03:52 -05:00
Greg Johnston
44a69bcf0a fix rkyv 2024-01-10 08:03:52 -05:00
Greg Johnston
46a64478ba clean up my mistake 2024-01-10 08:03:52 -05:00
Greg Johnston
1aa3f50dd6 FromStr-based lightweight ServerFnError deserialization 2024-01-10 08:03:52 -05:00
Greg Johnston
5e9fe95992 properly gate inventory 2024-01-10 08:03:52 -05:00
benwis
4756d6c434 Made some progress, started work on pavex integration as well 2024-01-10 08:03:52 -05:00
benwis
cef2263657 It starts to compile! 2024-01-10 08:03:51 -05:00
benwis
c1883d0607 Setup folder structure as before. Got a cyclical dependency though 2024-01-10 08:03:51 -05:00
benwis
ca8ba0ca8d First commit, checkpoint for cyclical dependency error 2024-01-10 08:03:51 -05:00
Daniel Santana
a8d98a1fde Update integration with support for axum 0.7 (#2082)
* chore: update to axum 0.7

Removed http, since it's included in axum, and replaced hyper by http-body-util, which is a smaller.

* chore: update samples to work with nre axum

Missing sessions_axum_auth, pending PR merge.

* chore: all dependencies update to axum 0.7

* chore: cargo fmt

* chore: fix doctests

* chore: Fix example that in reality doesn't use axum.

Fixed anyway.

* chore: more examples support for axum 0.7

* Small tweak
2024-01-10 08:03:51 -05:00
216 changed files with 11272 additions and 6099 deletions

View File

@@ -22,7 +22,6 @@ jobs:
[
integrations/actix,
integrations/axum,
integrations/viz,
integrations/utils,
leptos,
leptos_config,

View File

@@ -16,7 +16,6 @@ members = [
# integrations
"integrations/actix",
"integrations/axum",
"integrations/viz",
"integrations/utils",
# libraries
@@ -26,22 +25,22 @@ members = [
exclude = ["benchmarks", "examples"]
[workspace.package]
version = "0.5.6"
version = "0.6.0-beta"
[workspace.dependencies]
leptos = { path = "./leptos", version = "0.5.6" }
leptos_dom = { path = "./leptos_dom", version = "0.5.6" }
leptos_hot_reload = { path = "./leptos_hot_reload", version = "0.5.6" }
leptos_macro = { path = "./leptos_macro", version = "0.5.6" }
leptos_reactive = { path = "./leptos_reactive", version = "0.5.6" }
leptos_server = { path = "./leptos_server", version = "0.5.6" }
server_fn = { path = "./server_fn", version = "0.5.6" }
server_fn_macro = { path = "./server_fn_macro", version = "0.5.6" }
server_fn_macro_default = { path = "./server_fn/server_fn_macro_default", version = "0.5.6" }
leptos_config = { path = "./leptos_config", version = "0.5.6" }
leptos_router = { path = "./router", version = "0.5.6" }
leptos_meta = { path = "./meta", version = "0.5.6" }
leptos_integration_utils = { path = "./integrations/utils", version = "0.5.6" }
leptos = { path = "./leptos", version = "0.6.0-beta" }
leptos_dom = { path = "./leptos_dom", version = "0.6.0-beta" }
leptos_hot_reload = { path = "./leptos_hot_reload", version = "0.6.0-beta" }
leptos_macro = { path = "./leptos_macro", version = "0.6.0-beta" }
leptos_reactive = { path = "./leptos_reactive", version = "0.6.0-beta" }
leptos_server = { path = "./leptos_server", version = "0.6.0-beta" }
server_fn = { path = "./server_fn", version = "0.6.0-beta" }
server_fn_macro = { path = "./server_fn_macro", version = "0.6.0-beta" }
server_fn_macro_default = { path = "./server_fn/server_fn_macro_default", version = "0.6" }
leptos_config = { path = "./leptos_config", version = "0.6.0-beta" }
leptos_router = { path = "./router", version = "0.6.0-beta" }
leptos_meta = { path = "./meta", version = "0.6.0-beta" }
leptos_integration_utils = { path = "./integrations/utils", version = "0.6.0-beta" }
[profile.release]
codegen-units = 1

View File

@@ -21,6 +21,7 @@ CARGO_MAKE_CRATE_WORKSPACE_MEMBERS = [
"login_with_token_csr_only",
"parent_child",
"router",
"server_fns_axum",
"session_auth_axum",
"slots",
"ssr_modes",
@@ -32,7 +33,6 @@ CARGO_MAKE_CRATE_WORKSPACE_MEMBERS = [
"timer",
"todo_app_sqlite",
"todo_app_sqlite_axum",
"todo_app_sqlite_viz",
"todomvc",
]

View File

@@ -0,0 +1,90 @@
[package]
name = "action-form-error-handling"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
actix-files = { version = "0.6", optional = true }
actix-web = { version = "4", optional = true, features = ["macros"] }
console_error_panic_hook = "0.1"
cfg-if = "1"
http = { version = "0.2", optional = true }
leptos = { path = "../../leptos" }
leptos_meta = { path = "../../meta" }
leptos_actix = { path = "../../integrations/actix", optional = true }
leptos_router = { path = "../../router" }
wasm-bindgen = "0.2"
serde = { version = "1", features = ["derive"] }
[features]
csr = ["leptos/csr", "leptos_meta/csr", "leptos_router/csr"]
hydrate = ["leptos/hydrate", "leptos_meta/hydrate", "leptos_router/hydrate"]
ssr = [
"dep:actix-files",
"dep:actix-web",
"dep:leptos_actix",
"leptos/ssr",
"leptos_meta/ssr",
"leptos_router/ssr",
]
# Defines a size-optimized profile for the WASM bundle in release mode
[profile.wasm-release]
inherits = "release"
opt-level = 'z'
lto = true
codegen-units = 1
panic = "abort"
[package.metadata.leptos]
# The name used by wasm-bindgen/cargo-leptos for the JS/WASM bundle. Defaults to the crate name
output-name = "leptos_start"
# 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/main.scss"
# 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.
# [Windows] for non-WSL use "npx.cmd playwright test"
# This binary name can be checked in Powershell with Get-Command npx
end2end-cmd = "npx playwright test"
end2end-dir = "end2end"
# The browserlist query used for optimizing the CSS.
browserquery = "defaults"
# Set by cargo-leptos watch when building with that 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
# The profile to use for the lib target when compiling for release
#
# Optional. Defaults to "release".
lib-profile-release = "wasm-release"

View File

@@ -5,4 +5,4 @@ extend = [
[env]
CLIENT_PROCESS_NAME = "todo_app_sqlite_viz"
CLIENT_PROCESS_NAME = "action_form_error_handling"

View File

@@ -0,0 +1,68 @@
<picture>
<source srcset="https://raw.githubusercontent.com/leptos-rs/leptos/main/docs/logos/Leptos_logo_Solid_White.svg" media="(prefers-color-scheme: dark)">
<img src="https://raw.githubusercontent.com/leptos-rs/leptos/main/docs/logos/Leptos_logo_RGB.svg" alt="Leptos Logo">
</picture>
# Leptos Starter Template
This is a template for use with the [Leptos](https://github.com/leptos-rs/leptos) web framework and the [cargo-leptos](https://github.com/akesson/cargo-leptos) tool.
## Creating your template repo
If you don't have `cargo-leptos` installed you can install it with
`cargo install cargo-leptos`
Then run
`cargo leptos new --git leptos-rs/start`
to generate a new project template (you will be prompted to enter a project name).
`cd {projectname}`
to go to your newly created project.
Of course, you should explore around the project structure, but the best place to start with your application code is in `src/app.rs`.
## Running your project
`cargo leptos watch`
By default, you can access your local project at `http://localhost:3000`
## Installing Additional Tools
By default, `cargo-leptos` uses `nightly` Rust, `cargo-generate`, and `sass`. If you run into any trouble, you may need to install one or more of these tools.
1. `rustup toolchain install nightly --allow-downgrade` - make sure you have Rust nightly
2. `rustup target add wasm32-unknown-unknown` - add the ability to compile Rust to WebAssembly
3. `cargo install cargo-generate` - install `cargo-generate` binary (should be installed automatically in future)
4. `npm install -g sass` - install `dart-sass` (should be optional in future)
## Executing a Server on a Remote Machine Without the Toolchain
After running a `cargo leptos build --release` the minimum files needed are:
1. The server binary located in `target/server/release`
2. The `site` directory and all files within located in `target/site`
Copy these files to your remote server. The directory structure should be:
```text
leptos_start
site/
```
Set the following environment variables (updating for your project as needed):
```sh
export LEPTOS_OUTPUT_NAME="leptos_start"
export LEPTOS_SITE_ROOT="site"
export LEPTOS_SITE_PKG_DIR="pkg"
export LEPTOS_SITE_ADDR="127.0.0.1:3000"
export LEPTOS_RELOAD_PORT="3001"
```
Finally, run the server binary.
## Notes about CSR and Trunk:
Although it is not recommended, you can also run your project without server integration using the feature `csr` and `trunk serve`:
`trunk serve --open --features csr`
This may be useful for integrating external tools which require a static site, e.g. `tauri`.

View File

@@ -0,0 +1,90 @@
use leptos::*;
use leptos_meta::*;
use leptos_router::*;
#[component]
pub fn App() -> impl IntoView {
// Provides context that manages stylesheets, titles, meta tags, etc.
provide_meta_context();
view! {
// injects a stylesheet into the document <head>
// id=leptos means cargo-leptos will hot-reload this stylesheet
<Stylesheet id="leptos" href="/pkg/leptos_start.css"/>
// sets the document title
<Title text="Welcome to Leptos"/>
// content for this welcome page
<Router>
<main id="app">
<Routes>
<Route path="" view=HomePage/>
<Route path="/*any" view=NotFound/>
</Routes>
</main>
</Router>
}
}
#[server]
async fn do_something(should_error: Option<String>) -> Result<String, ServerFnError> {
if should_error.is_none() {
Ok(String::from("Successful submit"))
} else {
Err(ServerFnError::ServerError(String::from(
"You got an error!",
)))
}
}
/// Renders the home page of your application.
#[component]
fn HomePage() -> impl IntoView {
let do_something_action = Action::<DoSomething, _>::server();
let value = Signal::derive(move || do_something_action.value().get().unwrap_or_else(|| Ok(String::new())));
Effect::new_isomorphic(move |_| {
logging::log!("Got value = {:?}", value.get());
});
view! {
<h1>"Test the action form!"</h1>
<ErrorBoundary fallback=move |error| format!("{:#?}", error
.get()
.into_iter()
.next()
.unwrap()
.1.into_inner()
.to_string())
>
{value}
<ActionForm action=do_something_action class="form">
<label>Should error: <input type="checkbox" name="should_error"/></label>
<button type="submit">Submit</button>
</ActionForm>
</ErrorBoundary>
}
}
/// 404 - Not Found
#[component]
fn NotFound() -> impl IntoView {
// set an HTTP status code 404
// this is feature gated because it can only be done during
// initial server-side rendering
// if you navigate to the 404 page subsequently, the status
// code will not be set because there is not a new HTTP request
// to the server
#[cfg(feature = "ssr")]
{
// this can be done inline because it's synchronous
// if it were async, we'd use a server function
let resp = expect_context::<leptos_actix::ResponseOptions>();
resp.set_status(actix_web::http::StatusCode::NOT_FOUND);
}
view! {
<h1>"Not Found"</h1>
}
}

View File

@@ -0,0 +1,18 @@
pub mod app;
use cfg_if::cfg_if;
cfg_if! {
if #[cfg(feature = "hydrate")] {
use wasm_bindgen::prelude::wasm_bindgen;
#[wasm_bindgen]
pub fn hydrate() {
use app::*;
console_error_panic_hook::set_once();
leptos::mount_to_body(App);
}
}
}

View File

@@ -0,0 +1,53 @@
#[cfg(feature = "ssr")]
#[actix_web::main]
async fn main() -> std::io::Result<()> {
use actix_files::Files;
use actix_web::*;
use leptos::*;
use leptos_actix::{generate_route_list, LeptosRoutes};
use action_form_error_handling::app::*;
let conf = get_configuration(None).await.unwrap();
let addr = conf.leptos_options.site_addr;
// Generate the list of routes in your Leptos App
let routes = generate_route_list(App);
println!("listening on http://{}", &addr);
HttpServer::new(move || {
let leptos_options = &conf.leptos_options;
let site_root = &leptos_options.site_root;
App::new()
.route("/api/{tail:.*}", leptos_actix::handle_server_fns())
// serve JS/WASM/CSS from `pkg`
.service(Files::new("/pkg", format!("{site_root}/pkg")))
.leptos_routes(leptos_options.to_owned(), routes.to_owned(), App)
.app_data(web::Data::new(leptos_options.to_owned()))
//.wrap(middleware::Compress::default())
})
.bind(&addr)?
.run()
.await
}
#[cfg(not(any(feature = "ssr", feature = "csr")))]
pub fn main() {
// no client-side main function
// unless we want this to work with e.g., Trunk for pure client-side testing
// see lib.rs for hydration function instead
// see optional feature `csr` instead
}
#[cfg(all(not(feature = "ssr"), feature = "csr"))]
pub fn main() {
// a client-side main function is required for using `trunk serve`
// prefer using `cargo leptos serve` instead
// to run: `trunk serve --open --features csr`
use leptos::*;
use action_form_error_handling::app::*;
use wasm_bindgen::prelude::wasm_bindgen;
console_error_panic_hook::set_once();
leptos::mount_to_body(App);
}

View File

@@ -0,0 +1,15 @@
body {
font-family: sans-serif;
text-align: center;
}
#app {
text-align: center;
}
.form {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
}

View File

@@ -17,7 +17,6 @@ broadcaster = "1"
console_log = "1"
console_error_panic_hook = "0.1"
futures = "0.3"
cfg-if = "1"
lazy_static = "1"
leptos = { path = "../../leptos" }
leptos_actix = { path = "../../integrations/actix", optional = true }

View File

@@ -1,34 +1,35 @@
use cfg_if::cfg_if;
use leptos::*;
use leptos_meta::*;
use leptos_router::*;
#[cfg(feature = "ssr")]
use tracing::instrument;
cfg_if! {
if #[cfg(feature = "ssr")] {
use std::sync::atomic::{AtomicI32, Ordering};
use broadcaster::BroadcastChannel;
use once_cell::sync::OnceCell;
#[cfg(feature = "ssr")]
mod ssr_imports {
pub use broadcaster::BroadcastChannel;
pub use once_cell::sync::OnceCell;
pub use std::sync::atomic::{AtomicI32, Ordering};
static COUNT: AtomicI32 = AtomicI32::new(0);
pub static COUNT: AtomicI32 = AtomicI32::new(0);
lazy_static::lazy_static! {
pub static ref COUNT_CHANNEL: BroadcastChannel<i32> = BroadcastChannel::new();
}
lazy_static::lazy_static! {
pub static ref COUNT_CHANNEL: BroadcastChannel<i32> = BroadcastChannel::new();
}
static LOG_INIT: OnceCell<()> = OnceCell::new();
fn init_logging() {
LOG_INIT.get_or_init(|| {
simple_logger::SimpleLogger::new().env().init().unwrap();
});
}
static LOG_INIT: OnceCell<()> = OnceCell::new();
pub fn init_logging() {
LOG_INIT.get_or_init(|| {
simple_logger::SimpleLogger::new().env().init().unwrap();
});
}
}
#[server]
#[cfg_attr(feature = "ssr", instrument)]
pub async fn get_server_count() -> Result<i32, ServerFnError> {
use ssr_imports::*;
Ok(COUNT.load(Ordering::Relaxed))
}
@@ -38,6 +39,8 @@ pub async fn adjust_server_count(
delta: i32,
msg: String,
) -> Result<i32, ServerFnError> {
use ssr_imports::*;
let new = COUNT.load(Ordering::Relaxed) + delta;
COUNT.store(new, Ordering::Relaxed);
_ = COUNT_CHANNEL.send(&new).await;
@@ -48,6 +51,8 @@ pub async fn adjust_server_count(
#[server]
#[cfg_attr(feature = "ssr", instrument)]
pub async fn clear_server_count() -> Result<i32, ServerFnError> {
use ssr_imports::*;
COUNT.store(0, Ordering::Relaxed);
_ = COUNT_CHANNEL.send(&0).await;
Ok(0)
@@ -55,7 +60,7 @@ pub async fn clear_server_count() -> Result<i32, ServerFnError> {
#[component]
pub fn Counters() -> impl IntoView {
#[cfg(feature = "ssr")]
init_logging();
ssr_imports::init_logging();
provide_meta_context();
view! {

View File

@@ -1,21 +1,13 @@
use cfg_if::cfg_if;
pub mod counters;
// 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 leptos::*;
use wasm_bindgen::prelude::wasm_bindgen;
use crate::counters::*;
#[cfg(feature = "hydrate")]
#[wasm_bindgen::prelude::wasm_bindgen]
pub fn hydrate() {
use crate::counters::*;
use leptos::*;
#[wasm_bindgen]
pub fn hydrate() {
_ = console_log::init_with_level(log::Level::Debug);
console_error_panic_hook::set_once();
_ = console_log::init_with_level(log::Level::Debug);
console_error_panic_hook::set_once();
mount_to_body(|| {
view! { <Counters/> }
});
}
}
mount_to_body(Counters);
}

View File

@@ -1,10 +1,5 @@
use cfg_if::cfg_if;
mod counters;
// boilerplate to run in different modes
cfg_if! {
// server-only stuff
if #[cfg(feature = "ssr")] {
use leptos::*;
use actix_files::{Files};
use actix_web::*;
@@ -43,7 +38,7 @@ cfg_if! {
let conf = get_configuration(None).await.unwrap();
let addr = conf.leptos_options.site_addr;
let routes = generate_route_list(|| view! { <Counters/> });
let routes = generate_route_list(Counters);
HttpServer::new(move || {
let leptos_options = &conf.leptos_options;
@@ -51,8 +46,7 @@ cfg_if! {
App::new()
.service(counter_events)
.route("/api/{tail:.*}", leptos_actix::handle_server_fns())
.leptos_routes(leptos_options.to_owned(), routes.to_owned(), || view! { <Counters/> })
.leptos_routes(leptos_options.to_owned(), routes.to_owned(), Counters)
.service(Files::new("/", site_root))
//.wrap(middleware::Compress::default())
})
@@ -60,13 +54,4 @@ cfg_if! {
.run()
.await
}
}
// client-only main for Trunk
else {
pub fn main() {
// isomorphic counters cannot work in a Client-Side-Rendered only
// app as a server is required to maintain state
}
}
}

View File

@@ -7,22 +7,21 @@ edition = "2021"
crate-type = ["cdylib", "rlib"]
[dependencies]
console_log = "1.0.0"
console_error_panic_hook = "0.1.7"
cfg-if = "1.0.0"
console_log = "1.0"
console_error_panic_hook = "0.1"
leptos = { path = "../../leptos", features = ["nightly"] }
leptos_axum = { path = "../../integrations/axum", optional = true }
leptos_meta = { path = "../../meta" }
leptos_router = { path = "../../router" }
log = "0.4.17"
log = "0.4"
serde = { version = "1", features = ["derive"] }
simple_logger = "4.0.0"
axum = { version = "0.6.1", optional = true }
tower = { version = "0.4.13", optional = true }
tower-http = { version = "0.4", features = ["fs"], optional = true }
tokio = { version = "1.22.0", features = ["full"], optional = true }
http = { version = "0.2.8" }
thiserror = "1.0.38"
simple_logger = "4.0"
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 }
http = { version = "1.0" }
thiserror = "1.0"
wasm-bindgen = "0.2"
[features]

View File

@@ -1,5 +1,4 @@
use crate::errors::AppError;
use cfg_if::cfg_if;
use leptos::{logging::log, Errors, *};
#[cfg(feature = "ssr")]
use leptos_axum::ResponseOptions;
@@ -30,12 +29,13 @@ pub fn ErrorTemplate(
// 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")] {
#[cfg(feature = "ssr")]
{
let response = use_context::<ResponseOptions>();
if let Some(response) = response {
response.set_status(errors[0].status_code());
}
}}
}
view! {
<h1>{if errors.len() > 1 {"Errors"} else {"Error"}}</h1>

View File

@@ -1,43 +1,48 @@
use cfg_if::cfg_if;
use crate::landing::App;
use axum::{
body::Body,
extract::State,
http::{Request, Response, StatusCode, Uri},
response::{IntoResponse, Response as AxumResponse},
};
use leptos::{view, LeptosOptions};
use tower::ServiceExt;
use tower_http::services::ServeDir;
cfg_if! { if #[cfg(feature = "ssr")] {
use axum::{
body::{boxed, Body, BoxBody},
extract::State,
response::IntoResponse,
http::{Request, Response, StatusCode, Uri},
};
use axum::response::Response as AxumResponse;
use tower::ServiceExt;
use tower_http::services::ServeDir;
use leptos::{LeptosOptions, view};
use crate::landing::App;
pub async fn file_and_error_handler(
uri: Uri,
State(options): State<LeptosOptions>,
req: Request<Body>,
) -> AxumResponse {
let root = options.site_root.clone();
let res = get_static_file(uri.clone(), &root).await.unwrap();
pub async fn file_and_error_handler(uri: Uri, State(options): State<LeptosOptions>, req: Request<Body>) -> 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 handler = leptos_axum::render_app_to_stream(
options.to_owned(),
move || view!{ <App/> }
);
handler(req).await.into_response()
}
if res.status() == StatusCode::OK {
res.into_response()
} else {
let handler = leptos_axum::render_app_to_stream(
options.to_owned(),
move || view! { <App/> },
);
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}"),
)),
}
async fn get_static_file(
uri: Uri,
root: &str,
) -> Result<Response<Body>, (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}"),
)),
}
}}
}

View File

@@ -1,24 +1,21 @@
use cfg_if::cfg_if;
pub mod error_template;
pub mod errors;
#[cfg(feature = "ssr")]
pub mod fallback;
pub mod landing;
// 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 leptos::*;
use wasm_bindgen::prelude::wasm_bindgen;
use crate::landing::*;
use wasm_bindgen::prelude::wasm_bindgen;
#[wasm_bindgen]
pub fn hydrate() {
_ = console_log::init_with_level(log::Level::Debug);
console_error_panic_hook::set_once();
#[cfg(feature = "hydrate")]
#[wasm_bindgen]
pub fn hydrate() {
use crate::landing::*;
use leptos::*;
leptos::mount_to_body(|| {
view! { <App/> }
});
}
}
_ = console_log::init_with_level(log::Level::Debug);
console_error_panic_hook::set_once();
leptos::mount_to_body(|| {
view! { <App/> }
});
}

View File

@@ -1,41 +1,39 @@
use cfg_if::cfg_if;
cfg_if! { if #[cfg(feature = "ssr")] {
use crate::fallback::file_and_error_handler;
use crate::landing::*;
use axum::body::Body as AxumBody;
use axum::{
extract::{State, Path},
#[cfg(feature = "ssr")]
mod ssr_imports {
pub use axum::{
body::Body as AxumBody,
extract::{Path, State},
http::Request,
response::{IntoResponse, Response},
routing::{get, post},
routing::get,
Router,
};
use errors_axum::*;
use leptos::{logging::log, *};
use leptos_axum::{generate_route_list, LeptosRoutes};
}}
pub use errors_axum::{fallback::*, landing::App};
pub use leptos::{logging::log, *};
pub use leptos_axum::{generate_route_list, LeptosRoutes};
//Define a handler to test extractor with state
#[cfg(feature = "ssr")]
async fn custom_handler(
Path(id): Path<String>,
State(options): State<LeptosOptions>,
req: Request<AxumBody>,
) -> Response {
let handler = leptos_axum::render_app_to_stream_with_context(
options.clone(),
move || {
provide_context(id.clone());
},
App,
);
handler(req).await.into_response()
// This custom handler lets us provide Axum State via context
pub async fn custom_handler(
Path(id): Path<String>,
State(options): State<LeptosOptions>,
req: Request<AxumBody>,
) -> Response {
let handler = leptos_axum::render_app_to_stream_with_context(
options.clone(),
move || {
provide_context(id.clone());
},
App,
);
handler(req).await.into_response()
}
}
#[cfg(feature = "ssr")]
#[tokio::main]
async fn main() {
use ssr_imports::*;
simple_logger::init_with_level(log::Level::Debug)
.expect("couldn't initialize logging");
@@ -52,7 +50,6 @@ async fn main() {
// build our application with a route
let app = Router::new()
.route("/api/*fn_name", post(leptos_axum::handle_server_fns))
.route("/special/:id", get(custom_handler))
.leptos_routes(&leptos_options, routes, App)
.fallback(file_and_error_handler)
@@ -61,8 +58,8 @@ async fn main() {
// 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())
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
axum::serve(listener, app.into_make_service())
.await
.unwrap();
}
@@ -71,5 +68,5 @@ async fn main() {
#[cfg(not(feature = "ssr"))]
pub fn main() {
// This example cannot be built as a trunk standalone CSR-only app.
// The server is needed to demonstrate the error statuses.
// The server is needed to demonstrate the error statuses.
}

View File

@@ -15,7 +15,6 @@ actix-files = { version = "0.6", optional = true }
actix-web = { version = "4", optional = true, features = ["macros"] }
console_log = "1"
console_error_panic_hook = "0.1"
cfg-if = "1"
leptos = { path = "../../leptos", features = ["nightly"] }
leptos_meta = { path = "../../meta", features = ["nightly"] }
leptos_actix = { path = "../../integrations/actix", optional = true }

View File

@@ -1,4 +1,3 @@
use cfg_if::cfg_if;
use leptos::*;
use leptos_meta::*;
use leptos_router::*;
@@ -33,16 +32,10 @@ pub fn App() -> impl IntoView {
}
}
// 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;
#[wasm_bindgen]
pub fn hydrate() {
_ = console_log::init_with_level(log::Level::Debug);
console_error_panic_hook::set_once();
leptos::mount_to_body(App);
}
}
#[cfg(feature = "hydrate")]
#[wasm_bindgen::prelude::wasm_bindgen]
pub fn hydrate() {
_ = console_log::init_with_level(log::Level::Debug);
console_error_panic_hook::set_once();
leptos::mount_to_body(App);
}

View File

@@ -1,56 +1,56 @@
use cfg_if::cfg_if;
use leptos::*;
// server-only stuff
#[cfg(feature = "ssr")]
mod ssr_imports {
pub use actix_files::Files;
pub use actix_web::*;
pub use hackernews::App;
pub use leptos_actix::{generate_route_list, LeptosRoutes};
// boilerplate to run in different modes
cfg_if! {
// server-only stuff
if #[cfg(feature = "ssr")] {
use actix_files::{Files};
use actix_web::*;
use hackernews::{App};
use leptos_actix::{LeptosRoutes, generate_route_list};
#[get("/style.css")]
async fn css() -> impl Responder {
actix_files::NamedFile::open_async("./style.css").await
}
#[get("/favicon.ico")]
async fn favicon() -> impl Responder {
actix_files::NamedFile::open_async("./target/site//favicon.ico").await
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
// Setting this to None means we'll be using cargo-leptos and its env vars.
let conf = get_configuration(None).await.unwrap();
let addr = conf.leptos_options.site_addr;
// Generate the list of routes in your Leptos App
let routes = generate_route_list(App);
HttpServer::new(move || {
let leptos_options = &conf.leptos_options;
let site_root = &leptos_options.site_root;
App::new()
.service(css)
.service(favicon)
.route("/api/{tail:.*}", leptos_actix::handle_server_fns())
.leptos_routes(leptos_options.to_owned(), routes.to_owned(), App)
.service(Files::new("/", site_root))
//.wrap(middleware::Compress::default())
})
.bind(&addr)?
.run()
.await
}
} else {
fn main() {
use hackernews::{App};
_ = console_log::init_with_level(log::Level::Debug);
console_error_panic_hook::set_once();
mount_to_body(App)
}
#[get("/style.css")]
pub async fn css() -> impl Responder {
actix_files::NamedFile::open_async("./style.css").await
}
#[get("/favicon.ico")]
pub async fn favicon() -> impl Responder {
actix_files::NamedFile::open_async("./target/site//favicon.ico").await
}
}
#[cfg(feature = "ssr")]
#[actix_web::main]
async fn main() -> std::io::Result<()> {
use leptos::get_configuration;
use ssr_imports::*;
// Setting this to None means we'll be using cargo-leptos and its env vars.
let conf = get_configuration(None).await.unwrap();
let addr = conf.leptos_options.site_addr;
// Generate the list of routes in your Leptos App
let routes = generate_route_list(App);
HttpServer::new(move || {
let leptos_options = &conf.leptos_options;
let site_root = &leptos_options.site_root;
App::new()
.service(css)
.service(favicon)
.leptos_routes(leptos_options.to_owned(), routes.to_owned(), App)
.service(Files::new("/", site_root))
//.wrap(middleware::Compress::default())
})
.bind(&addr)?
.run()
.await
}
// CSR-only setup
#[cfg(not(feature = "ssr"))]
fn main() {
use hackernews::App;
_ = console_log::init_with_level(log::Level::Debug);
console_error_panic_hook::set_once();
mount_to_body(App)
}

View File

@@ -11,24 +11,23 @@ codegen-units = 1
lto = true
[dependencies]
console_log = "1.0.0"
console_error_panic_hook = "0.1.7"
cfg-if = "1.0.0"
console_log = "1.0"
console_error_panic_hook = "0.1"
leptos = { path = "../../leptos", features = ["nightly"] }
leptos_axum = { path = "../../integrations/axum", optional = true }
leptos_meta = { path = "../../meta", features = ["nightly"] }
leptos_router = { path = "../../router", features = ["nightly"] }
log = "0.4.17"
simple_logger = "4.0.0"
serde = { version = "1.0.148", features = ["derive"] }
log = "0.4"
simple_logger = "4.0"
serde = { version = "1.0", features = ["derive"] }
tracing = "0.1"
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.4", features = ["fs"], optional = true }
tokio = { version = "1.22.0", features = ["full"], optional = true }
http = { version = "0.2.11", optional = true }
gloo-net = { version = "0.4", features = ["http"] }
reqwest = { version = "0.11", features = ["json"] }
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 }
http = { version = "1.0", optional = true }
web-sys = { version = "0.3", features = ["AbortController", "AbortSignal"] }
wasm-bindgen = "0.2"

View File

@@ -1,44 +1,48 @@
use cfg_if::cfg_if;
use crate::error_template::error_template;
use axum::{
body::Body,
extract::State,
http::{Request, Response, StatusCode, Uri},
response::{IntoResponse, Response as AxumResponse},
};
use leptos::LeptosOptions;
use tower::ServiceExt;
use tower_http::services::ServeDir;
cfg_if! {
if #[cfg(feature = "ssr")] {
use axum::{
body::{boxed, Body, BoxBody},
extract::State,
response::IntoResponse,
http::{Request, Response, StatusCode, Uri},
};
use axum::response::Response as AxumResponse;
use tower::ServiceExt;
use tower_http::services::ServeDir;
use leptos::{LeptosOptions};
use crate::error_template::error_template;
pub async fn file_and_error_handler(
uri: Uri,
State(options): State<LeptosOptions>,
req: Request<Body>,
) -> AxumResponse {
let root = options.site_root.clone();
let res = get_static_file(uri.clone(), &root).await.unwrap();
pub async fn file_and_error_handler(uri: Uri, State(options): State<LeptosOptions>, req: Request<Body>) -> 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 handler = leptos_axum::render_app_to_stream(options.to_owned(), || error_template( None));
handler(req).await.into_response()
}
if res.status() == StatusCode::OK {
res.into_response()
} else {
let handler =
leptos_axum::render_app_to_stream(options.to_owned(), || {
error_template(None)
});
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),
)),
}
async fn get_static_file(
uri: Uri,
root: &str,
) -> Result<Response<Body>, (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),
)),
}
}
}

View File

@@ -1,63 +1,68 @@
use cfg_if::cfg_if;
use axum::{
body::Body,
http::{Request, Response, StatusCode, Uri},
response::IntoResponse,
};
use tower::ServiceExt;
use tower_http::services::ServeDir;
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<Body>, (StatusCode, String)> {
let res = get_static_file(uri.clone(), "/pkg").await?;
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)
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(),
)),
}
}
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()))
}
}
} else {
Ok(res)
}
}
pub async fn get_static_file_handler(
uri: Uri,
) -> Result<Response<Body>, (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<Body>, (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.into_response()),
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.into_response()),
Err(err) => Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("Something went wrong: {}", err),
)),
}
} else {
Err((StatusCode::NOT_FOUND, "Not Found".to_string()))
}
}

View File

@@ -1,10 +1,11 @@
use cfg_if::cfg_if;
use leptos::{component, view, IntoView};
use leptos_meta::*;
use leptos_router::*;
mod api;
pub mod error_template;
#[cfg(feature = "ssr")]
pub mod fallback;
#[cfg(feature = "ssr")]
pub mod handlers;
mod routes;
use routes::{nav::*, stories::*, story::*, users::*};
@@ -12,38 +13,28 @@ use routes::{nav::*, stories::*, story::*, users::*};
#[component]
pub fn App() -> impl IntoView {
provide_meta_context();
view! {
<>
<Link rel="shortcut icon" type_="image/ico" href="/favicon.ico"/>
<Stylesheet id="leptos" href="/pkg/hackernews_axum.css"/>
<Meta name="description" content="Leptos implementation of a HackerNews demo."/>
<Router>
<Nav />
<main>
<Routes>
<Route path="users/:id" view=User/>
<Route path="stories/:id" view=Story/>
<Route path=":stories?" view=Stories/>
</Routes>
</main>
</Router>
</>
<Link rel="shortcut icon" type_="image/ico" href="/favicon.ico"/>
<Stylesheet id="leptos" href="/pkg/hackernews_axum.css"/>
<Meta name="description" content="Leptos implementation of a HackerNews demo."/>
<Router>
<Nav />
<main>
<Routes>
<Route path="users/:id" view=User/>
<Route path="stories/:id" view=Story/>
<Route path=":stories?" view=Stories/>
</Routes>
</main>
</Router>
}
}
// 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;
#[wasm_bindgen]
pub fn hydrate() {
_ = console_log::init_with_level(log::Level::Debug);
console_error_panic_hook::set_once();
leptos::mount_to_body(move || {
view! { <App/> }
});
}
}
#[cfg(feature = "hydrate")]
#[wasm_bindgen::prelude::wasm_bindgen]
pub fn hydrate() {
_ = console_log::init_with_level(log::Level::Debug);
console_error_panic_hook::set_once();
leptos::mount_to_body(App);
}

View File

@@ -1,54 +1,41 @@
use cfg_if::cfg_if;
use leptos::{logging::log, *};
// boilerplate to run in different modes
cfg_if! {
if #[cfg(feature = "ssr")] {
use axum::{
Router,
routing::get,
};
#[cfg(feature = "ssr")]
#[tokio::main]
async fn main() {
use axum::{routing::get, Router};
use hackernews_axum::{fallback::file_and_error_handler, *};
use leptos::get_configuration;
use leptos_axum::{generate_route_list, LeptosRoutes};
use hackernews_axum::fallback::file_and_error_handler;
#[tokio::main]
async fn main() {
use hackernews_axum::*;
let conf = get_configuration(Some("Cargo.toml")).await.unwrap();
let leptos_options = conf.leptos_options;
let addr = leptos_options.site_addr;
let routes = generate_route_list(App);
let conf = get_configuration(Some("Cargo.toml")).await.unwrap();
let leptos_options = conf.leptos_options;
let addr = leptos_options.site_addr;
let routes = generate_route_list(App);
simple_logger::init_with_level(log::Level::Debug)
.expect("couldn't initialize logging");
simple_logger::init_with_level(log::Level::Debug).expect("couldn't initialize logging");
// build our application with a route
let app = Router::new()
// build our application with a route
let app = Router::new()
.route("/favicon.ico", get(file_and_error_handler))
.leptos_routes(&leptos_options, routes, || view! { <App/> } )
.leptos_routes(&leptos_options, routes, App)
.fallback(file_and_error_handler)
.with_state(leptos_options);
// 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();
}
// run our app with hyper
// `axum::Server` is a re-export of `hyper::Server`
println!("listening on {}", addr);
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
axum::serve(listener, app.into_make_service())
.await
.unwrap();
}
// client-only stuff for Trunk
else {
use hackernews_axum::*;
// client-only stuff for Trunk
#[cfg(not(feature = "ssr"))]
pub fn main() {
use hackernews_axum::*;
pub fn main() {
_ = console_log::init_with_level(log::Level::Debug);
console_error_panic_hook::set_once();
mount_to_body(|| {
view! { <App/> }
});
}
}
_ = console_log::init_with_level(log::Level::Debug);
console_error_panic_hook::set_once();
mount_to_body(App);
}

View File

@@ -11,9 +11,8 @@ codegen-units = 1
lto = true
[dependencies]
console_log = "1.0.0"
console_error_panic_hook = "0.1.7"
cfg-if = "1.0.0"
console_log = "1.0"
console_error_panic_hook = "0.1"
leptos = { path = "../../leptos", features = [
"nightly",
"experimental-islands",
@@ -23,20 +22,20 @@ leptos_axum = { path = "../../integrations/axum", optional = true, features = [
] }
leptos_meta = { path = "../../meta", features = ["nightly"] }
leptos_router = { path = "../../router", features = ["nightly"] }
log = "0.4.17"
simple_logger = "4.0.0"
serde = { version = "1.0.148", features = ["derive"] }
log = "0.4"
simple_logger = "4.0"
serde = { version = "1.0", features = ["derive"] }
tracing = "0.1"
gloo-net = { version = "0.2.5", features = ["http"] }
reqwest = { version = "0.11.13", features = ["json"] }
axum = { version = "0.6.1", optional = true, features = ["http2"] }
tower = { version = "0.4.13", optional = true }
tower-http = { version = "0.4", features = [
gloo-net = { version = "0.4", features = ["http"] }
reqwest = { version = "0.11", features = ["json"] }
axum = { version = "0.7", optional = true, features = ["http2"] }
tower = { version = "0.4", optional = true }
tower-http = { version = "0.5", features = [
"fs",
"compression-br",
], optional = true }
tokio = { version = "1.22.0", features = ["full"], optional = true }
http = { version = "0.2.11", optional = true }
tokio = { version = "1", features = ["full"], optional = true }
http = { version = "1.0", optional = true }
web-sys = { version = "0.3", features = ["AbortController", "AbortSignal"] }
wasm-bindgen = "0.2"
lazy_static = "1.4.0"

View File

@@ -1,44 +1,48 @@
use cfg_if::cfg_if;
use crate::error_template::error_template;
use axum::{
body::Body,
extract::State,
http::{Request, Response, StatusCode, Uri},
response::{IntoResponse, Response as AxumResponse},
};
use leptos::LeptosOptions;
use tower::ServiceExt;
use tower_http::services::ServeDir;
cfg_if! {
if #[cfg(feature = "ssr")] {
use axum::{
body::{boxed, Body, BoxBody},
extract::State,
response::IntoResponse,
http::{Request, Response, StatusCode, Uri},
};
use axum::response::Response as AxumResponse;
use tower::ServiceExt;
use tower_http::services::ServeDir;
use leptos::{LeptosOptions};
use crate::error_template::error_template;
pub async fn file_and_error_handler(
uri: Uri,
State(options): State<LeptosOptions>,
req: Request<Body>,
) -> AxumResponse {
let root = options.site_root.clone();
let res = get_static_file(uri.clone(), &root).await.unwrap();
pub async fn file_and_error_handler(uri: Uri, State(options): State<LeptosOptions>, req: Request<Body>) -> 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 handler = leptos_axum::render_app_to_stream(options.to_owned(), || error_template( None));
handler(req).await.into_response()
}
if res.status() == StatusCode::OK {
res.into_response()
} else {
let handler =
leptos_axum::render_app_to_stream(options.to_owned(), || {
error_template(None)
});
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),
)),
}
async fn get_static_file(
uri: Uri,
root: &str,
) -> Result<Response<Body>, (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),
)),
}
}
}

View File

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

@@ -1,11 +1,11 @@
#![feature(lazy_cell)]
use cfg_if::cfg_if;
use leptos::*;
use leptos_meta::*;
use leptos_router::*;
mod api;
pub mod error_template;
#[cfg(feature = "ssr")]
pub mod fallback;
mod routes;
use routes::{nav::*, stories::*, story::*, users::*};
@@ -31,16 +31,10 @@ pub fn App() -> impl IntoView {
}
}
// 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;
#[wasm_bindgen]
pub fn hydrate() {
#[cfg(debug_assertions)]
console_error_panic_hook::set_once();
leptos::leptos_dom::HydrationCtx::stop_hydrating();
}
}
#[cfg(feature = "hydrate")]
#[wasm_bindgen::prelude::wasm_bindgen]
pub fn hydrate() {
#[cfg(debug_assertions)]
console_error_panic_hook::set_once();
leptos::leptos_dom::HydrationCtx::stop_hydrating();
}

View File

@@ -1,16 +1,11 @@
#[cfg(feature = "ssr")]
mod ssr_imports {
pub use axum::{routing::get, Router};
pub use hackernews_islands::fallback::file_and_error_handler;
pub use leptos::*;
pub use leptos_axum::{generate_route_list, LeptosRoutes};
}
#[cfg(feature = "ssr")]
#[tokio::main]
async fn main() {
pub use axum::{routing::get, Router};
pub use hackernews_islands::fallback::file_and_error_handler;
use hackernews_islands::*;
use ssr_imports::*;
pub use leptos::get_configuration;
pub use leptos_axum::{generate_route_list, LeptosRoutes};
let conf = get_configuration(Some("Cargo.toml")).await.unwrap();
let leptos_options = conf.leptos_options;
@@ -27,8 +22,8 @@ async fn main() {
// run our app with hyper
// `axum::Server` is a re-export of `hyper::Server`
logging::log!("listening on {}", addr);
axum::Server::bind(&addr)
.serve(app.into_make_service())
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
axum::serve(listener, app.into_make_service())
.await
.unwrap();
}
@@ -40,7 +35,5 @@ pub fn main() {
use leptos::*;
_ = console_log::init_with_level(log::Level::Debug);
console_error_panic_hook::set_once();
mount_to_body(|| {
view! { <App/> }
});
mount_to_body(App);
}

View File

@@ -11,22 +11,21 @@ codegen-units = 1
lto = true
[dependencies]
console_log = "1.0.0"
console_error_panic_hook = "0.1.7"
cfg-if = "1.0.0"
console_log = "1.0"
console_error_panic_hook = "0.1"
leptos = { path = "../../leptos", features = ["nightly"] }
leptos_axum = { path = "../../integrations/axum", default-features = false, optional = true }
leptos_meta = { path = "../../meta", features = ["nightly"] }
leptos_router = { path = "../../router", features = ["nightly"] }
log = "0.4.17"
simple_logger = "4.0.0"
serde = { version = "1.0.148", features = ["derive"] }
log = "0.4"
simple_logger = "4.0"
serde = { version = "1.0", features = ["derive"] }
tracing = "0.1"
gloo-net = { version = "0.4.0", features = ["http"] }
reqwest = { version = "0.11.13", features = ["json"] }
axum = { version = "0.6", default-features = false, optional = true }
tower = { version = "0.4.13", optional = true }
http = { version = "0.2.11", optional = true }
gloo-net = { version = "0.4", features = ["http"] }
reqwest = { version = "0.11", features = ["json"] }
axum = { version = "0.7", default-features = false, optional = true }
tower = { version = "0.4", optional = true }
http = { version = "1.0", optional = true }
web-sys = { version = "0.3", features = [
"AbortController",
"AbortSignal",
@@ -34,10 +33,10 @@ web-sys = { version = "0.3", features = [
"Response",
] }
wasm-bindgen = "0.2"
wasm-bindgen-futures = { version = "0.4.37", features = [
wasm-bindgen-futures = { version = "0.4", features = [
"futures-core-03-stream",
], optional = true }
axum-js-fetch = { version = "0.2.1", optional = true }
axum-js-fetch = { version = "0.2", optional = true }
lazy_static = "1.4.0"
[features]

View File

@@ -1,39 +1,43 @@
use cfg_if::cfg_if;
use crate::error_template::error_template;
use axum::{
body::Body,
extract::State,
http::{Request, Response, StatusCode, Uri},
response::{IntoResponse, Response as AxumResponse},
};
//use tower::ServiceExt;
use leptos::LeptosOptions;
cfg_if! {
if #[cfg(feature = "ssr")] {
use axum::{
body::{Body, BoxBody},
extract::State,
response::IntoResponse,
http::{Request, Response, StatusCode, Uri},
};
use axum::response::Response as AxumResponse;
//use tower::ServiceExt;
use leptos::{LeptosOptions};
use crate::error_template::error_template;
pub async fn file_and_error_handler(
uri: Uri,
State(options): State<LeptosOptions>,
req: Request<Body>,
) -> AxumResponse {
let root = options.site_root.clone();
let res = get_static_file(uri.clone(), &root).await.unwrap();
pub async fn file_and_error_handler(uri: Uri, State(options): State<LeptosOptions>, req: Request<Body>) -> 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 handler = leptos_axum::render_app_to_stream(options.to_owned(), || error_template(None));
handler(req).await.into_response()
}
if res.status() == StatusCode::OK {
res.into_response()
} else {
let handler =
leptos_axum::render_app_to_stream(options.to_owned(), || {
error_template(None)
});
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
_ = req;
_ = root;
todo!()
}
}
async fn get_static_file(
uri: Uri,
root: &str,
) -> Result<Response<Body>, (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
_ = req;
_ = root;
todo!()
}

View File

@@ -1,9 +1,9 @@
use cfg_if::cfg_if;
use leptos::{component, view, IntoView};
use leptos_meta::*;
use leptos_router::*;
mod api;
pub mod error_template;
#[cfg(feature = "ssr")]
pub mod fallback;
mod routes;
use routes::{nav::*, stories::*, story::*, users::*};
@@ -29,25 +29,22 @@ pub fn App() -> impl IntoView {
}
}
cfg_if! {
if #[cfg(feature = "hydrate")] {
#[wasm_bindgen]
pub fn hydrate() {
_ = console_log::init_with_level(log::Level::Debug);
console_error_panic_hook::set_once();
leptos::mount_to_body(move || {
view! { <App/> }
});
}
} else if #[cfg(feature = "ssr")] {
#[cfg(feature = "hydrate")]
#[wasm_bindgen]
pub fn hydrate() {
_ = console_log::init_with_level(log::Level::Debug);
console_error_panic_hook::set_once();
leptos::mount_to_body(App);
}
use axum::{
Router,
routing::post
};
use leptos_axum::{generate_route_list, LeptosRoutes};
#[cfg(feature = "ssr")]
mod ssr_imports {
use crate::App;
use axum::Router;
use leptos::*;
use leptos_axum::{generate_route_list, LeptosRoutes};
use log::{info, Level};
use wasm_bindgen::prelude::wasm_bindgen;
#[wasm_bindgen]
pub struct Handler(axum_js_fetch::App);
@@ -58,14 +55,16 @@ cfg_if! {
console_log::init_with_level(Level::Debug);
console_error_panic_hook::set_once();
let leptos_options = LeptosOptions::builder().output_name("client").site_pkg_dir("pkg").build();
let leptos_options = LeptosOptions::builder()
.output_name("client")
.site_pkg_dir("pkg")
.build();
let routes = generate_route_list(App);
// build our application with a route
let app: axum::Router<(), axum::body::Body> = Router::new()
.leptos_routes(&leptos_options, routes, || view! { <App/> } )
.route("/api/*fn_name", post(leptos_axum::handle_server_fns))
.with_state(leptos_options);
let app: axum::Router = Router::new()
.leptos_routes(&leptos_options, routes, App)
.with_state(leptos_options);
info!("creating handler instance");
@@ -77,4 +76,3 @@ cfg_if! {
}
}
}
}

View File

@@ -14,7 +14,7 @@ leptos_router = { path = "../../../router", features = ["csr"] }
log = "0.4"
console_error_panic_hook = "0.1"
console_log = "1"
gloo-net = "0.2"
gloo-storage = "0.2"
gloo-net = "0.5"
gloo-storage = "0.3"
serde = "1.0"
thiserror = "1.0"

View File

@@ -1,5 +1,5 @@
use api_boundary::*;
use gloo_net::http::{Request, Response};
use gloo_net::http::{Request, RequestBuilder, Response};
use serde::de::DeserializeOwned;
use thiserror::Error;
@@ -41,7 +41,7 @@ impl AuthorizedApi {
fn auth_header_value(&self) -> String {
format!("Bearer {}", self.token.token)
}
async fn send<T>(&self, req: Request) -> Result<T>
async fn send<T>(&self, req: RequestBuilder) -> Result<T>
where
T: DeserializeOwned,
{

View File

@@ -5,14 +5,18 @@ edition = "2021"
publish = false
[dependencies]
api-boundary = "=0.0.0"
anyhow = "1.0"
api-boundary = "*"
axum = { version = "0.6", features = ["headers"] }
axum = "0.7"
axum-extra = { version = "0.9.2", features = ["typed-header"] }
env_logger = "0.10"
log = "0.4"
mailparse = "0.14"
pwhash = "1.0"
thiserror = "1.0"
tokio = { version = "1.25", features = ["macros", "rt-multi-thread"] }
tower-http = { version = "0.4", features = ["cors"] }
uuid = { version = "1.3", features = ["v4"] }
tokio = { version = "1.35", features = ["macros", "rt-multi-thread"] }
tower-http = { version = "0.5", features = ["cors"] }
uuid = { version = "1.6", features = ["v4"] }
parking_lot = "0.12.1"
headers = "0.4.0"

View File

@@ -1,38 +1,36 @@
use mailparse::addrparse;
use pwhash::bcrypt;
use std::{collections::HashMap, str::FromStr, sync::RwLock};
use std::{collections::HashMap, str::FromStr};
use thiserror::Error;
use uuid::Uuid;
#[derive(Default)]
pub struct AppState {
users: RwLock<HashMap<EmailAddress, Password>>,
tokens: RwLock<HashMap<Uuid, EmailAddress>>,
users: HashMap<EmailAddress, Password>,
tokens: HashMap<Uuid, EmailAddress>,
}
impl AppState {
pub fn create_user(
&self,
&mut self,
credentials: Credentials,
) -> Result<(), CreateUserError> {
let Credentials { email, password } = credentials;
let user_exists = self.users.read().unwrap().get(&email).is_some();
let user_exists = self.users.get(&email).is_some();
if user_exists {
return Err(CreateUserError::UserExists);
}
self.users.write().unwrap().insert(email, password);
self.users.insert(email, password);
Ok(())
}
pub fn login(
&self,
&mut self,
email: EmailAddress,
password: &str,
) -> Result<Uuid, LoginError> {
let valid_credentials = self
.users
.read()
.unwrap()
.get(&email)
.map(|hashed_password| hashed_password.verify(password))
.unwrap_or(false);
@@ -40,16 +38,16 @@ impl AppState {
Err(LoginError::InvalidEmailOrPassword)
} else {
let token = Uuid::new_v4();
self.tokens.write().unwrap().insert(token, email);
self.tokens.insert(token, email);
Ok(token)
}
}
pub fn logout(&self, token: &str) -> Result<(), LogoutError> {
pub fn logout(&mut self, token: &str) -> Result<(), LogoutError> {
let token = token
.parse::<Uuid>()
.map_err(|_| LogoutError::NotLoggedIn)?;
self.tokens.write().unwrap().remove(&token);
self.tokens.remove(&token);
Ok(())
}
@@ -62,8 +60,6 @@ impl AppState {
.map_err(|_| AuthError::NotAuthorized)
.and_then(|token| {
self.tokens
.read()
.unwrap()
.get(&token)
.cloned()
.map(|email| CurrentUser { email, token })

View File

@@ -1,13 +1,16 @@
use api_boundary as json;
use axum::{
extract::{State, TypedHeader},
headers::{authorization::Bearer, Authorization},
extract::State,
http::Method,
response::Json,
routing::{get, post},
Router,
};
use std::{env, sync::Arc};
use axum_extra::TypedHeader;
use headers::{authorization::Bearer, Authorization};
use parking_lot::RwLock;
use std::{env, net::SocketAddr, sync::Arc};
use tokio::net::TcpListener;
use tower_http::cors::{Any, CorsLayer};
mod adapters;
@@ -32,7 +35,7 @@ async fn main() -> anyhow::Result<()> {
}
env_logger::init();
let shared_state = Arc::new(AppState::default());
let shared_state = Arc::new(RwLock::new(AppState::default()));
let cors_layer = CorsLayer::new()
.allow_methods([Method::GET, Method::POST])
@@ -46,11 +49,10 @@ async fn main() -> anyhow::Result<()> {
.route_layer(cors_layer)
.with_state(shared_state);
let addr = "0.0.0.0:3000".parse().unwrap();
log::info!("Listen on {addr}");
axum::Server::bind(&addr)
.serve(app.into_make_service())
.await?;
let addr = "0.0.0.0:3000".parse::<SocketAddr>()?;
log::info!("Start listening on http://{addr}");
let listener = TcpListener::bind(addr).await?;
axum::serve(listener, app.into_make_service()).await?;
Ok(())
}
@@ -73,40 +75,43 @@ enum Error {
}
async fn create_user(
State(state): State<Arc<AppState>>,
State(state): State<Arc<RwLock<AppState>>>,
Json(credentials): Json<json::Credentials>,
) -> Result<()> {
let credentials = Credentials::try_from(credentials)?;
state.create_user(credentials)?;
state.write().create_user(credentials)?;
Ok(Json(()))
}
async fn login(
State(state): State<Arc<AppState>>,
State(state): State<Arc<RwLock<AppState>>>,
Json(credentials): Json<json::Credentials>,
) -> Result<json::ApiToken> {
let json::Credentials { email, password } = credentials;
log::debug!("{email} tries to login");
let email = email.parse().map_err(|_|
// Here we don't want to leak detailed info.
LoginError::InvalidEmailOrPassword)?;
let token = state.login(email, &password).map(|s| s.to_string())?;
// Here we don't want to leak detailed info.
LoginError::InvalidEmailOrPassword)?;
let token = state
.write()
.login(email, &password)
.map(|s| s.to_string())?;
Ok(Json(json::ApiToken { token }))
}
async fn logout(
State(state): State<Arc<AppState>>,
State(state): State<Arc<RwLock<AppState>>>,
TypedHeader(auth): TypedHeader<Authorization<Bearer>>,
) -> Result<()> {
state.logout(auth.token())?;
state.write().logout(auth.token())?;
Ok(Json(()))
}
async fn get_user_info(
State(state): State<Arc<AppState>>,
State(state): State<Arc<RwLock<AppState>>>,
TypedHeader(auth): TypedHeader<Authorization<Bearer>>,
) -> Result<json::UserInfo> {
let user = state.authorize_user(auth.token())?;
let user = state.read().authorize_user(auth.token())?;
let CurrentUser { email, .. } = user;
Ok(Json(json::UserInfo {
email: email.into_string(),

3
examples/pavex_demo/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
/target
.env
.direnv

View File

@@ -0,0 +1,92 @@
[workspace]
members = ["todo_app_sqlite_pavex", "todo_app_sqlite_pavex_server_sdk", "todo_app_sqlite_pavex_server", "leptos_app"]
# By setting `todo_app_sqlite_pavex_server` as the default member, `cargo run` will default to running the server binary
# when executed from the root of the workspace.
# Otherwise, you would have to use `cargo run --bin api` to run the server binary.
default-members = ["todo_app_sqlite_pavex_server"]
resolver = "2"
# need to be applied only to wasm build
[profile.wasm_release]
codegen-units = 1
lto = true
opt-level = 'z'
[workspace.dependencies]
leptos = { version = "0.5", features = ["nightly"] }
leptos_meta = { version = "0.5", features = ["nightly"] }
leptos_router = { version = "0.5", features = ["nightly"] }
leptos_pavex = { version = "0.5" }
cfg_if = "1"
thiserror = "1"
# See https://github.com/akesson/cargo-leptos for documentation of all the parameters.
# A leptos project defines which workspace members
# that are used together frontend (lib) & server (bin)
[[workspace.metadata.leptos]]
# this name is used for the wasm, js and css file names
name = "start-pavex-workspace"
# the package in the workspace that contains the server binary (binary crate)
bin-package = "server"
# the package in the workspace that contains the frontend wasm binary (library crate)
lib-package = "leptos_frontend"
# 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/main.scss"
# Assets source dir. All files found here will be copied and synchronized to site-root.
# The assets-dir cannot have a sub directory with the same name/path as site-pkg-dir.
#
# Optional. Env: LEPTOS_ASSETS_DIR.
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.
# [Windows] for non-WSL use "npx.cmd playwright test"
# This binary name can be checked in Powershell with Get-Command npx
end2end-cmd = "npx playwright test"
end2end-dir = "end2end"
# The browserlist query used for optimizing the CSS.
browserquery = "defaults"
# Set by cargo-leptos watch when building with that 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 = []
# 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 = []
# 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,71 @@
# todo_app_sqlite_pavex
# Getting started
## Prerequisites
- Rust (see [here](https://www.rust-lang.org/tools/install) for instructions)
- `cargo-px`:
```bash
cargo install --locked cargo-px --version="~0.1"
```
- [Pavex](https://pavex.dev)
## Useful commands
`todo_app_sqlite_pavex` is built using the [Pavex](https://pavex.dev) web framework, which relies on code generation.
You need to use the `cargo px` command instead of `cargo`: it ensures that the
`todo_app_sqlite_pavex_server_sdk` crate is correctly regenerated when the
application blueprint changes.
`cargo px` is a wrapper around `cargo` that will automatically regenerate the
server SDK when needed. Check out its [documentation](https://github.com/LukeMathWalker/cargo-px)
for more details.
### Build
```bash
cargo px build
```
### Run
```bash
cargo px run
```
### Test
```bash
cargo px test
```
## Configuration
All configurable parameters are listed in `todo_app_sqlite_pavex/src/configuration.rs`.
Configuration values are loaded from two sources:
- Configuration files
- Environment variables
Environment variables take precedence over configuration files.
All configuration files are in the `todo_app_sqlite_pavex_server/configuration` folder.
The application can be run in three different profiles: `dev`, `test` and `prod`.
The settings that you want to share across all profiles should be placed in `todo_app_sqlite_pavex_server/configuration/base.yml`.
Profile-specific configuration files can be then used
to override or supply additional values on top of the default settings (e.g. `todo_app_sqlite_pavex_server/configuration/dev.yml`).
You can specify the app profile that you want to use by setting the `APP_PROFILE` environment variable; e.g.:
```bash
APP_PROFILE=prod cargo px run
```
for running the application with the `prod` profile.
By default, the `dev` profile is used since `APP_PROFILE` is set to `dev` in the `.env` file at the root of the project.
The `.env` file should not be committed to version control: it is meant to be used for local development only,
so that each developer can specify their own environment variables for secret values (e.g. database credentials)
that shouldn't be stored in configuration files (given their sensitive nature).

119
examples/pavex_demo/flake.lock generated Normal file
View File

@@ -0,0 +1,119 @@
{
"nodes": {
"cargo-pavex-git": {
"flake": false,
"locked": {
"lastModified": 1703610192,
"narHash": "sha256-+oM6VGRRt/DQdhEFWJFIpKfY29w72V0vRpud8NsOI7c=",
"owner": "LukeMathWalker",
"repo": "pavex",
"rev": "e302f99e3641a55fe5624ba6c8154ce64e732a89",
"type": "github"
},
"original": {
"owner": "LukeMathWalker",
"repo": "pavex",
"type": "github"
}
},
"cargo-px-git": {
"flake": false,
"locked": {
"lastModified": 1702137928,
"narHash": "sha256-FbwHEOQnIYKhxp4Ne9XBIUJXu1o+ak6y9MhzRenIW40=",
"owner": "LukeMathWalker",
"repo": "cargo-px",
"rev": "d1bb9075c4993130f31f31c95642567a2255bd8e",
"type": "github"
},
"original": {
"owner": "LukeMathWalker",
"repo": "cargo-px",
"type": "github"
}
},
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1701680307,
"narHash": "sha256-kAuep2h5ajznlPMD9rnQyffWG8EM/C73lejGofXvdM8=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "4022d587cbbfd70fe950c1e2083a02621806a725",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1703499205,
"narHash": "sha256-lF9rK5mSUfIZJgZxC3ge40tp1gmyyOXZ+lRY3P8bfbg=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "e1fa12d4f6c6fe19ccb59cac54b5b3f25e160870",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"cargo-pavex-git": "cargo-pavex-git",
"cargo-px-git": "cargo-px-git",
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs",
"rust-overlay": "rust-overlay"
}
},
"rust-overlay": {
"inputs": {
"flake-utils": [
"flake-utils"
],
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1703643208,
"narHash": "sha256-UL4KO8JxnD5rOycwHqBAf84lExF1/VnYMDC7b/wpPDU=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "ce117f3e0de8262be8cd324ee6357775228687cf",
"type": "github"
},
"original": {
"owner": "oxalica",
"repo": "rust-overlay",
"type": "github"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

View File

@@ -0,0 +1,129 @@
{
description = "Build Pavex tools";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
flake-utils.url = "github:numtide/flake-utils";
cargo-px-git = {
url = "github:/LukeMathWalker/cargo-px";
flake = false;
};
cargo-pavex-git = {
url = "github:LukeMathWalker/pavex";
flake = false;
};
rust-overlay = {
url = "github:oxalica/rust-overlay";
inputs = {
nixpkgs.follows = "nixpkgs";
flake-utils.follows = "flake-utils";
};
};
};
outputs = { self, nixpkgs, flake-utils, rust-overlay, ... } @inputs:
flake-utils.lib.eachDefaultSystem
(system:
let
pkgs = import nixpkgs {
inherit system;
overlays = [ (import rust-overlay) ];
};
inherit (pkgs) lib;
rustTarget = pkgs.rust-bin.selectLatestNightlyWith( toolchain: toolchain.default.override {
extensions = [ "rust-src" "rust-analyzer" "rustc-codegen-cranelift-preview" "rust-docs-json"];
targets = [ "wasm32-unknown-unknown" ];
});
cargo-pavex_cli-git = pkgs.rustPlatform.buildRustPackage rec {
pname = "cargo-pavex-cli";
version = "0.2.22";
#buildFeatures = ["no_downloads"]; # cargo-leptos will try to download Ruby and other things without this feature
src = inputs.cargo-pavex-git;
sourceRoot = "source/libs";
cargoLock = {
lockFile = inputs.cargo-pavex-git + "/libs/Cargo.lock";
outputHashes = {
"matchit-0.7.3" = "sha256-1bhbWvLlDb6/UJ4j2FqoG7j3DD1dTOLl6RaiY9kasmQ=";
#"pavex-0.1.0" = "sha256-NC7T1pcXJiWPtAWeiMUNzf2MUsYaRYxjLIL9fCqhExo=";
};
};
#buildAndTestSubdir = "libs";
cargoSha256 = "";
nativeBuildInputs = [pkgs.pkg-config pkgs.openssl pkgs.git];
buildInputs = with pkgs;
[openssl pkg-config git]
++ lib.optionals stdenv.isDarwin [
Security
];
doCheck = false; # integration tests depend on changing cargo config
meta = with lib; {
description = "An easy-to-use Rust framework for building robust and performant APIs";
homepage = "https://github.com/LukeMatthewWalker/pavex";
changelog = "https://github.com/LukeMatthewWalker/pavex/blob/v${version}/CHANGELOG.md";
license = with licenses; [mit];
maintainers = with maintainers; [benwis];
};
};
cargo-px-git = pkgs.rustPlatform.buildRustPackage rec {
pname = "cargo-px";
version = "0.2.22";
#buildFeatures = ["no_downloads"]; # cargo-leptos will try to download Ruby and other things without this feature
src = inputs.cargo-px-git;
cargoSha256 ="sha256-+pyeqh0IoZ1JMgbhWxhEJw1MPgG7XeocVrqJoSNjgDA=";
nativeBuildInputs = [pkgs.pkg-config pkgs.openssl pkgs.git];
buildInputs = with pkgs;
[openssl pkg-config git]
++ lib.optionals stdenv.isDarwin [
Security
];
doCheck = false; # integration tests depend on changing cargo config
meta = with lib; {
description = "A cargo subcommand that extends cargo's capabilities when it comes to code generation.";
homepage = "https://github.com/LukeMatthewWalker/cargo-px";
changelog = "https://github.com/LukeMatthewWalker/cargo-px/blob/v${version}/CHANGELOG.md";
license = with licenses; [mit];
maintainers = with maintainers; [benwis];
};
};
in
{
devShells.default = pkgs.mkShell {
# Extra inputs can be added here
nativeBuildInputs = with pkgs; [
#rustTarget
rustup
openssl
pkg-config
clang
tailwindcss
mold-wrapped
cargo-px-git
cargo-pavex_cli-git
];
#RUST_SRC_PATH = "${rustTarget}/lib/rustlib/src/rust/library";
MOLD_PATH = "${pkgs.mold-wrapped}/bin/mold";
shellHook = ''
sed -i -e '/rustflags = \["-C", "link-arg=-fuse-ld=/ s|ld=.*|ld=${pkgs.mold-wrapped}/bin/mold"]|' .cargo/config.toml
'';
LIBCLANG_PATH = "${pkgs.llvmPackages.libclang.lib}/lib";
};
});
}

View File

@@ -0,0 +1,21 @@
[package]
name = "leptos_app"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
leptos.workspace = true
leptos_meta.workspace = true
leptos_router.workspace = true
leptos_pavex = { workspace = true, optional = true }
#http.workspace = true
cfg_if.workspace = true
thiserror.workspace = true
[features]
default = []
hydrate = ["leptos/hydrate", "leptos_meta/hydrate", "leptos_router/hydrate"]
ssr = ["leptos/ssr", "leptos_meta/ssr", "leptos_router/ssr", "dep:leptos_pavex"]

View File

@@ -0,0 +1,73 @@
use cfg_if::cfg_if;
use http::status::StatusCode;
use leptos::*;
#[cfg(feature = "ssr")]
use leptos_axum::ResponseOptions;
use thiserror::Error;
#[derive(Clone, Debug, Error)]
pub enum AppError {
#[error("Not Found")]
NotFound,
}
impl AppError {
pub fn status_code(&self) -> StatusCode {
match self {
AppError::NotFound => StatusCode::NOT_FOUND,
}
}
}
// A basic function to display errors served by the error boundaries.
// Feel free to do more complicated things here than just displaying the error.
#[component]
pub fn ErrorTemplate(
#[prop(optional)] outside_errors: Option<Errors>,
#[prop(optional)] errors: Option<RwSignal<Errors>>,
) -> 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
let errors = errors.get_untracked();
// Downcast lets us take a type that implements `std::error::Error`
let errors: Vec<AppError> = errors
.into_iter()
.filter_map(|(_k, v)| v.downcast_ref::<AppError>().cloned())
.collect();
println!("Errors: {errors:#?}");
// 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>();
if let Some(response) = response {
response.set_status(errors[0].status_code());
}
}}
view! {
<h1>{if errors.len() > 1 { "Errors" } else { "Error" }}</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
children=move |error| {
let error_string = error.1.to_string();
let error_code = error.1.status_code();
view! {
<h2>{error_code.to_string()}</h2>
<p>"Error: " {error_string}</p>
}
}
/>
}
}

View File

@@ -0,0 +1,45 @@
use crate::error_template::{AppError, ErrorTemplate};
use leptos::*;
use leptos_meta::*;
use leptos_router::*;
pub mod error_template;
#[component]
pub fn App() -> impl IntoView {
// Provides context that manages stylesheets, titles, meta tags, etc.
provide_meta_context();
view! {
<Stylesheet id="leptos" href="/pkg/start-axum-workspace.css"/>
// sets the document title
<Title text="Welcome to Leptos"/>
// content for this welcome page
<Router fallback=|| {
let mut outside_errors = Errors::default();
outside_errors.insert_with_default_key(AppError::NotFound);
view! { <ErrorTemplate outside_errors/> }.into_view()
}>
<main>
<Routes>
<Route path="" view=HomePage/>
</Routes>
</main>
</Router>
}
}
/// Renders the home page of your application.
#[component]
fn HomePage() -> impl IntoView {
// Creates a reactive value to update the button
let (count, set_count) = create_signal(0);
let on_click = move |_| set_count.update(|count| *count += 1);
view! {
<h1>"Welcome to Leptos on Pavex!"</h1>
<button on:click=on_click>"Click Me: " {count}</button>
}
}

View File

@@ -0,0 +1,8 @@
[package]
name = "leptos_front"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]

View File

@@ -0,0 +1,13 @@
use leptos::*;
use leptos_app::*;
use wasm_bindgen::prelude::wasm_bindgen;
#[wasm_bindgen]
pub fn hydrate() {
// initializes logging using the `log` crate
_ = console_log::init_with_level(log::Level::Debug);
console_error_panic_hook::set_once();
leptos::mount_to_body(App);
}

View File

@@ -0,0 +1,4 @@
body {
font-family: sans-serif;
text-align: center;
}

View File

@@ -0,0 +1,22 @@
[package]
name = "todo_app_sqlite_pavex"
version = "0.1.0"
edition = "2021"
[[bin]]
path = "src/bin/bp.rs"
name = "bp"
[dependencies]
cargo_px_env = "0.1"
pavex = { git = "https://github.com/LukeMathWalker/pavex", branch = "main" }
pavex_cli_client = { git = "https://github.com/LukeMathWalker/pavex", branch = "main" }
tracing = "0.1"
# Configuration
serde = { version = "1", features = ["derive"] }
serde-aux = "4"
# Leptos
leptos_pavex.workspace = true

View File

@@ -0,0 +1,17 @@
use cargo_px_env::generated_pkg_manifest_path;
use todo_app_sqlite_pavex::blueprint;
use pavex_cli_client::Client;
use std::error::Error;
/// Generate the `todo_app_sqlite_pavex_server_sdk` crate using Pavex's CLI.
///
/// Pavex will automatically wire all our routes, constructors and error handlers
/// into the a "server SDK" that can be used by the final API server binary to launch
/// the application.
fn main() -> Result<(), Box<dyn Error>> {
let generated_dir = generated_pkg_manifest_path()?.parent().unwrap().into();
Client::new()
.generate(blueprint(), generated_dir)
.execute()?;
Ok(())
}

View File

@@ -0,0 +1,98 @@
use leptos_pavex::{LeptosOptions, RouteListing};
use pavex::{
blueprint::{
constructor::{CloningStrategy, Lifecycle},
router::{ANY, GET},
Blueprint,
},
f,
};
/// The main blueprint, containing all the routes, constructors and error handlers
/// required by our API.
pub fn blueprint() -> Blueprint {
let mut bp = Blueprint::new();
register_common_constructors(&mut bp);
bp.constructor(
f!(crate::user_agent::UserAgent::extract),
Lifecycle::RequestScoped,
)
.error_handler(f!(crate::user_agent::invalid_user_agent));
add_telemetry_middleware(&mut bp);
bp.route(GET, "/test/ping", f!(crate::routes::status::ping));
bp.route(GET, "/test/greet/:name", f!(crate::routes::greet::greet));
// Handle all /api requests as those are Leptos server fns
bp.route(ANY, "/api/*fn_name", f!(leptos_pavex::handle_server_fns));
bp.route(ANY, "/");
bp.fallback(f!(file_handler));
bp
}
/// Common constructors used by all routes.
fn register_common_constructors(bp: &mut Blueprint) {
// Configuration Options
bp.constructor(
f!(crate::leptos::get_cargo_leptos_conf(), Lifecycle::Singleton),
Lifecycle::Singleton,
);
// List of Routes
bp.constructor(
f!(crate::leptos::get_app_route_listing(), Lifecycle::Singleton),
Lifecycle::Singleton,
);
bp.constructor(
f!(leptos_pavex::PavexRequest::extract),
LifeCycle::RequestScoped,
);
// Query parameters
bp.constructor(
f!(pavex::request::query::QueryParams::extract),
Lifecycle::RequestScoped,
)
.error_handler(f!(
pavex::request::query::errors::ExtractQueryParamsError::into_response
));
// Route parameters
bp.constructor(
f!(pavex::request::route::RouteParams::extract),
Lifecycle::RequestScoped,
)
.error_handler(f!(
pavex::request::route::errors::ExtractRouteParamsError::into_response
));
// Json body
bp.constructor(
f!(pavex::request::body::JsonBody::extract),
Lifecycle::RequestScoped,
)
.error_handler(f!(
pavex::request::body::errors::ExtractJsonBodyError::into_response
));
bp.constructor(
f!(pavex::request::body::BufferedBody::extract),
Lifecycle::RequestScoped,
)
.error_handler(f!(
pavex::request::body::errors::ExtractBufferedBodyError::into_response
));
bp.constructor(
f!(<pavex::request::body::BodySizeLimit as std::default::Default>::default),
Lifecycle::RequestScoped,
);
}
/// Add the telemetry middleware, as well as the constructors of its dependencies.
fn add_telemetry_middleware(bp: &mut Blueprint) {
bp.constructor(
f!(crate::telemetry::RootSpan::new),
Lifecycle::RequestScoped,
)
.cloning(CloningStrategy::CloneIfNecessary);
bp.wrap(f!(crate::telemetry::logger));
}

View File

@@ -0,0 +1,32 @@
use pavex::server::IncomingStream;
use serde_aux::field_attributes::deserialize_number_from_string;
use std::net::SocketAddr;
#[derive(serde::Deserialize)]
/// The top-level configuration, holding all the values required
/// to configure the entire application.
pub struct Config {
pub server: ServerConfig,
}
#[derive(serde::Deserialize, Clone)]
/// Configuration for the HTTP server used to expose our API
/// to users.
pub struct ServerConfig {
/// The port that the server must listen on.
#[serde(deserialize_with = "deserialize_number_from_string")]
pub port: u16,
/// The network interface that the server must be bound to.
///
/// E.g. `0.0.0.0` for listening to incoming requests from
/// all sources.
pub ip: std::net::IpAddr,
}
impl ServerConfig {
/// Bind a TCP listener according to the specified parameters.
pub async fn listener(&self) -> Result<IncomingStream, std::io::Error> {
let addr = SocketAddr::new(self.ip, self.port);
IncomingStream::bind(addr).await
}
}

View File

@@ -0,0 +1,45 @@
use app::error_template::AppError;
use app::error_template::ErrorTemplate;
use app::App;
use axum::response::Response as AxumResponse;
use axum::{
body::{boxed, Body, BoxBody},
extract::State,
http::{Request, Response, StatusCode, Uri},
response::IntoResponse,
};
use leptos::*;
use tower::ServiceExt;
use tower_http::services::ServeDir;
pub async fn file_and_error_handler(
uri: Uri,
State(options): State<LeptosOptions>,
req: Request<Body>,
) -> 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 handler = leptos_axum::render_app_to_stream(options.to_owned(), move || view! { <App/> });
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,19 @@
use leptos::{get_configuration, leptos_config::ConfFile};
use leptos_pavex::generate_route_list;
use leptos_router::RouteListing;
use pavex::{
http::header::{ToStrError, USER_AGENT},
request::RequestHead,
response::Response,
};
/// Easier to do this to avoid having to register things with Blueprints
/// Provide LeptosOptions via env vars provided by cargo-leptos or the user
pub fn get_cargo_leptos_conf() -> ConfFile {
get_configuration(None)
}
/// Generate all possible non server fn routes for our app
pub fn get_app_route_listing() -> Vec<RouteListing> {
generate_route_list(TodoApp)
}

View File

@@ -0,0 +1,7 @@
mod blueprint;
pub mod configuration;
pub mod leptos;
pub mod routes;
pub mod telemetry;
pub mod user_agent;
pub use blueprint::blueprint;

View File

@@ -0,0 +1,21 @@
use crate::user_agent::UserAgent;
use pavex::{request::route::RouteParams, response::Response};
#[RouteParams]
pub struct GreetParams {
pub name: String,
}
pub fn greet(
params: RouteParams<GreetParams>,
user_agent: UserAgent,
) -> Response {
if let UserAgent::Unknown = user_agent {
return Response::unauthorized()
.set_typed_body("You must provide a `User-Agent` header")
.box_body();
}
let GreetParams { name } = params.0;
Response::ok()
.set_typed_body(format!("Hello, {name}!"))
.box_body()
}

View File

@@ -0,0 +1,3 @@
pub mod greet;
pub mod status;

View File

@@ -0,0 +1,7 @@
use pavex::http::StatusCode;
/// Respond with a `200 OK` status code to indicate that the server is alive
/// and ready to accept new requests.
pub fn ping() -> StatusCode {
StatusCode::OK
}

View File

@@ -0,0 +1,84 @@
use pavex::request::route::MatchedRouteTemplate;
use pavex::http::Version;
use pavex::middleware::Next;
use pavex::request::RequestHead;
use pavex::response::Response;
use std::borrow::Cow;
use std::future::IntoFuture;
use tracing::Instrument;
/// A logging middleware that wraps the request pipeline in the root span.
/// It takes care to record key information about the request and the response.
pub async fn logger<T>(next: Next<T>, root_span: RootSpan) -> Response
where
T: IntoFuture<Output = Response>,
{
let response = next
.into_future()
.instrument(root_span.clone().into_inner())
.await;
root_span.record_response_data(&response);
response
}
/// A root span is the top-level *logical* span for an incoming request.
///
/// It is not necessarily the top-level *physical* span, as it may be a child of
/// another span (e.g. a span representing the underlying HTTP connection).
///
/// We use the root span to attach as much information as possible about the
/// incoming request, and to record the final outcome of the request (success or
/// failure).
#[derive(Debug, Clone)]
pub struct RootSpan(tracing::Span);
impl RootSpan {
/// Create a new root span for the given request.
///
/// We follow OpenTelemetry's HTTP semantic conventions as closely as
/// possible for field naming.
pub fn new(request_head: &RequestHead, matched_route: MatchedRouteTemplate) -> Self {
let user_agent = request_head
.headers
.get("User-Agent")
.map(|h| h.to_str().unwrap_or_default())
.unwrap_or_default();
let span = tracing::info_span!(
"HTTP request",
http.method = %request_head.method,
http.flavor = %http_flavor(request_head.version),
user_agent.original = %user_agent,
http.response.status_code = tracing::field::Empty,
http.route = %matched_route,
http.target = %request_head.uri.path_and_query().map(|p| p.as_str()).unwrap_or(""),
);
Self(span)
}
pub fn record_response_data(&self, response: &Response) {
self.0
.record("http.response.status_code", &response.status().as_u16());
}
/// Get a reference to the underlying [`tracing::Span`].
pub fn inner(&self) -> &tracing::Span {
&self.0
}
/// Deconstruct the root span into its underlying [`tracing::Span`].
pub fn into_inner(self) -> tracing::Span {
self.0
}
}
fn http_flavor(version: Version) -> Cow<'static, str> {
match version {
Version::HTTP_09 => "0.9".into(),
Version::HTTP_10 => "1.0".into(),
Version::HTTP_11 => "1.1".into(),
Version::HTTP_2 => "2.0".into(),
Version::HTTP_3 => "3.0".into(),
other => format!("{other:?}").into(),
}
}

View File

@@ -0,0 +1,27 @@
use pavex::{
http::header::{ToStrError, USER_AGENT},
request::RequestHead,
response::Response,
};
pub enum UserAgent {
/// No User-Agent header was provided
Unknown,
/// The value of the 'User-Agent' header for the incoming request
Known(String),
}
impl UserAgent {
pub fn extract(request_head: &RequestHead) -> Result<Self, ToStrError> {
let Some(user_agent) = request_head.headers.get(USER_AGENT) else {
return Ok(UserAgent::Unknown);
};
user_agent.to_str().map(|s| UserAgent::Known(s.into()))
}
}
pub fn invalid_user_agent(_e: &ToStrError) -> Response {
Response::bad_request()
.set_typed_body("The `User-Agent` header must be a valid UTF-8 string")
.box_body()
}

View File

@@ -0,0 +1,29 @@
[package]
name = "todo_app_sqlite_pavex_server"
version = "0.1.0"
edition = "2021"
[[bin]]
path = "src/bin/api.rs"
name = "api"
[dependencies]
anyhow = "1"
pavex = { git = "https://github.com/LukeMathWalker/pavex", branch = "main" }
tokio = { version = "1", features = ["full"] }
todo_app_sqlite_pavex_server_sdk = { path = "../todo_app_sqlite_pavex_server_sdk" }
todo_app_sqlite_pavex = { path = "../todo_app_sqlite_pavex" }
# Configuration
dotenvy = "0.15"
figment = { version = "0.10", features = ["env", "yaml"] }
serde = { version = "1", features = ["derive"]}
# Telemetry
tracing = "0.1"
tracing-bunyan-formatter = "0.3"
tracing-panic = "0.1"
tracing-subscriber = { version = "0.3", default-features = false, features = ["env-filter", "registry", "smallvec", "std", "tracing-log"] }
[dev-dependencies]
reqwest = "0.11"

View File

@@ -0,0 +1,3 @@
server:
ip: "0.0.0.0"
port: 8000

View File

@@ -0,0 +1,6 @@
# This file contains the configuration for the dev environment.
# None of the values here are actually secret, so it's fine
# to commit this file to the repository.
server:
ip: "127.0.0.1"
port: 8000

View File

@@ -0,0 +1,3 @@
server:
ip: "0.0.0.0"
port: 8000

View File

@@ -0,0 +1,8 @@
# This file contains the configuration for the API when spawned
# in black-box tests.
# None of the values here are actually secret, so it's fine
# to commit this file to the repository.
server:
ip: "127.0.0.1"
# The OS will assign a random port to the test server.
port: 0

View File

@@ -0,0 +1,49 @@
use anyhow::Context;
use todo_app_sqlite_pavex_server::{
configuration::load_configuration,
telemetry::{get_subscriber, init_telemetry},
};
use todo_app_sqlite_pavex_server_sdk::{build_application_state, run};
use pavex::server::Server;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let subscriber = get_subscriber("todo_app_sqlite_pavex".into(), "info".into(), std::io::stdout);
init_telemetry(subscriber)?;
// We isolate all the server setup and launch logic in a separate function
// in order to have a single choke point where we make sure to log fatal errors
// that will cause the application to exit.
if let Err(e) = _main().await {
tracing::error!(
error.msg = %e,
error.error_chain = ?e,
"The application is exiting due to an error"
)
}
Ok(())
}
async fn _main() -> anyhow::Result<()> {
// Load environment variables from a .env file, if it exists.
let _ = dotenvy::dotenv();
let config = load_configuration(None)?;
let application_state = build_application_state()
.await;
let tcp_listener = config
.server
.listener()
.await
.context("Failed to bind the server TCP listener")?;
let address = tcp_listener
.local_addr()
.context("The server TCP listener doesn't have a local socket address")?;
let server_builder = Server::new().listen(tcp_listener);
tracing::info!("Starting to listen for incoming requests at {}", address);
run(server_builder, application_state).await;
Ok(())
}

View File

@@ -0,0 +1,140 @@
use std::env::VarError;
use anyhow::Context;
use todo_app_sqlite_pavex::configuration::Config;
use figment::{
providers::{Env, Format, Yaml},
Figment,
};
/// Retrieve the application configuration by merging together multiple configuration sources.
///
/// # Application profiles
///
/// We use the concept of application profiles to allow for
/// different configuration values depending on the type of environment
/// the application is running in.
///
/// We don't rely on `figment`'s built-in support for profiles because
/// we want to make sure that values for different profiles are not co-located in
/// the same configuration file.
/// This makes it easier to avoid leaking sensitive information by mistake (e.g.
/// by committing configuration values for the `dev` profile to the repository).
///
/// You primary mechanism to specify the desired application profile is the `APP_PROFILE`
/// environment variable.
/// You can pass a `default_profile` value that will be used if the environment variable
/// is not set.
///
/// # Hierarchy
///
/// The configuration sources are:
///
/// 1. `base.yml` - Contains the default configuration values, common to all profiles.
/// 2. `<profile>.yml` - Contains the configuration values specific to the desired profile.
/// 3. Environment variables - Contains the configuration values specific to the current environment.
///
/// The configuration sources are listed in priority order, i.e.
/// the last source in the list will override any previous source.
///
/// For example, if the same configuration key is defined in both
/// the YAML file and the environment, the value from the environment
/// will be used.
pub fn load_configuration(
default_profile: Option<ApplicationProfile>,
) -> Result<Config, anyhow::Error> {
let application_profile = load_app_profile(default_profile)
.context("Failed to load the desired application profile")?;
let configuration_dir = {
let manifest_dir = env!(
"CARGO_MANIFEST_DIR",
"`CARGO_MANIFEST_DIR` was not set. Are you using a custom build system?"
);
std::path::Path::new(manifest_dir).join("configuration")
};
let base_filepath = configuration_dir.join("base.yml");
let profile_filename = format!("{}.yml", application_profile.as_str());
let profile_filepath = configuration_dir.join(profile_filename);
let figment = Figment::new()
.merge(Yaml::file(base_filepath))
.merge(Yaml::file(profile_filepath))
.merge(Env::prefixed("APP_"));
let configuration: Config = figment
.extract()
.context("Failed to load hierarchical configuration")?;
Ok(configuration)
}
/// Load the application profile from the `APP_PROFILE` environment variable.
fn load_app_profile(
default_profile: Option<ApplicationProfile>,
) -> Result<ApplicationProfile, anyhow::Error> {
static PROFILE_ENV_VAR: &str = "APP_PROFILE";
match std::env::var(PROFILE_ENV_VAR) {
Ok(raw_value) => raw_value.parse().with_context(|| {
format!("Failed to parse the `{PROFILE_ENV_VAR}` environment variable")
}),
Err(VarError::NotPresent) if default_profile.is_some() => Ok(default_profile.unwrap()),
Err(e) => Err(anyhow::anyhow!(e).context(format!(
"Failed to read the `{PROFILE_ENV_VAR}` environment variable"
))),
}
}
/// The application profile, i.e. the type of environment the application is running in.
/// See [`load_configuration`] for more details.
pub enum ApplicationProfile {
/// Test profile.
///
/// This is the profile used by the integration test suite.
Test,
/// Local development profile.
///
/// This is the profile you should use when running the application locally
/// for exploratory testing.
///
/// The corresponding configuration file is `dev.yml` and it's *never* committed to the repository.
Dev,
/// Production profile.
///
/// This is the profile you should use when running the application in production—e.g.
/// when deploying it to a staging or production environment, exposed to live traffic.
///
/// The corresponding configuration file is `prod.yml`.
/// It's committed to the repository, but it's meant to contain exclusively
/// non-sensitive configuration values.
Prod,
}
impl ApplicationProfile {
/// Return the environment as a string.
pub fn as_str(&self) -> &'static str {
match self {
ApplicationProfile::Test => "test",
ApplicationProfile::Dev => "dev",
ApplicationProfile::Prod => "prod",
}
}
}
impl std::str::FromStr for ApplicationProfile {
type Err = anyhow::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"test" => Ok(ApplicationProfile::Test),
"dev" | "development" => Ok(ApplicationProfile::Dev),
"prod" | "production" => Ok(ApplicationProfile::Prod),
s => Err(anyhow::anyhow!(
"`{}` is not a valid application profile.\nValid options are: `test`, `dev`, `prod`.",
s
)),
}
}
}

View File

@@ -0,0 +1,2 @@
pub mod configuration;
pub mod telemetry;

View File

@@ -0,0 +1,40 @@
use anyhow::Context;
use tracing::subscriber::set_global_default;
use tracing::Subscriber;
use tracing_bunyan_formatter::{BunyanFormattingLayer, JsonStorageLayer};
use tracing_subscriber::fmt::MakeWriter;
use tracing_subscriber::{layer::SubscriberExt, EnvFilter, Registry};
/// Perform all the required setup steps for our telemetry:
///
/// - Register a subscriber as global default to process span data
/// - Register a panic hook to capture any panic and record its details
///
/// It should only be called once!
pub fn init_telemetry(subscriber: impl Subscriber + Sync + Send) -> Result<(), anyhow::Error> {
std::panic::set_hook(Box::new(tracing_panic::panic_hook));
set_global_default(subscriber).context("Failed to set a `tracing` global subscriber")
}
/// Compose multiple layers into a `tracing`'s subscriber.
///
/// # Implementation Notes
///
/// We are using `impl Subscriber` as return type to avoid having to spell out the actual
/// type of the returned subscriber, which is indeed quite complex.
pub fn get_subscriber<Sink>(
application_name: String,
default_env_filter: String,
sink: Sink,
) -> impl Subscriber + Sync + Send
where
Sink: for<'a> MakeWriter<'a> + Send + Sync + 'static,
{
let env_filter =
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(default_env_filter));
let formatting_layer = BunyanFormattingLayer::new(application_name, sink);
Registry::default()
.with(env_filter)
.with(JsonStorageLayer)
.with(formatting_layer)
}

View File

@@ -0,0 +1,37 @@
use crate::helpers::TestApi;
use pavex::http::StatusCode;
#[tokio::test]
async fn greet_happy_path() {
let api = TestApi::spawn().await;
let name = "Ursula";
let response = api
.api_client
.get(&format!("{}/api/greet/{name}", &api.api_address))
.header("User-Agent", "Test runner")
.send()
.await
.expect("Failed to execute request.");
assert_eq!(response.status().as_u16(), StatusCode::OK.as_u16());
assert_eq!(response.text().await.unwrap(), "Hello, Ursula!");
}
#[tokio::test]
async fn non_utf8_agent_is_rejected() {
let api = TestApi::spawn().await;
let name = "Ursula";
let response = api
.api_client
.get(&format!("{}/api/greet/{name}", &api.api_address))
.header("User-Agent", b"hello\xfa".as_slice())
.send()
.await
.expect("Failed to execute request.");
assert_eq!(response.status().as_u16(), StatusCode::BAD_REQUEST.as_u16());
assert_eq!(
response.text().await.unwrap(),
"The `User-Agent` header must be a valid UTF-8 string"
);
}

View File

@@ -0,0 +1,52 @@
use todo_app_sqlite_pavex_server::configuration::{load_configuration, ApplicationProfile};
use todo_app_sqlite_pavex_server_sdk::{build_application_state, run};
use todo_app_sqlite_pavex::configuration::Config;
use pavex::server::Server;
pub struct TestApi {
pub api_address: String,
pub api_client: reqwest::Client,
}
impl TestApi {
pub async fn spawn() -> Self {
let config = Self::get_config();
let application_state = build_application_state().await;
let tcp_listener = config
.server
.listener()
.await
.expect("Failed to bind the server TCP listener");
let address = tcp_listener
.local_addr()
.expect("The server TCP listener doesn't have a local socket address");
let server_builder = Server::new().listen(tcp_listener);
tokio::spawn(async move {
run(server_builder, application_state).await
});
TestApi {
api_address: format!("http://{}:{}", config.server.ip, address.port()),
api_client: reqwest::Client::new(),
}
}
fn get_config() -> Config {
load_configuration(Some(ApplicationProfile::Test)).expect("Failed to load test configuration")
}
}
/// Convenient methods for calling the API under test.
impl TestApi {
pub async fn get_ping(&self) -> reqwest::Response
{
self.api_client
.get(&format!("{}/api/ping", &self.api_address))
.send()
.await
.expect("Failed to execute request.")
}
}

View File

@@ -0,0 +1,4 @@
mod greet;
mod helpers;
mod ping;

View File

@@ -0,0 +1,11 @@
use crate::helpers::TestApi;
use pavex::http::StatusCode;
#[tokio::test]
async fn ping_works() {
let api = TestApi::spawn().await;
let response = api.get_ping().await;
assert_eq!(response.status().as_u16(), StatusCode::OK.as_u16());
}

View File

@@ -0,0 +1,21 @@
[package]
name = "todo_app_sqlite_pavex_server_sdk"
version = "0.1.0"
edition = "2021"
[package.metadata.px.generate]
generator_type = "cargo_workspace_binary"
generator_name = "bp"
[lints]
clippy = { all = "allow" }
[dependencies]
bytes = { version = "1.5.0", package = "bytes" }
http = { version = "1.0.0", package = "http" }
http_body_util = { version = "0.1.0", package = "http-body-util" }
hyper = { version = "1.1.0", package = "hyper" }
matchit = { version = "0.7.3", git = "https://github.com/ibraheemdev/matchit", branch = "master", package = "matchit" }
pavex = { version = "0.1.0", git = "https://github.com/LukeMathWalker/pavex", branch = "main", package = "pavex" }
thiserror = { version = "1.0.52", package = "thiserror" }
todo_app_sqlite_pavex = { version = "0.1.0", path = "../todo_app_sqlite_pavex", package = "todo_app_sqlite_pavex" }

View File

@@ -0,0 +1,233 @@
(
creation_location: (
line: 13,
column: 18,
file: "todo_app_sqlite_pavex/src/blueprint.rs",
),
constructors: [
(
constructor: (
callable: (
registered_at: "todo_app_sqlite_pavex",
import_path: "pavex::request::query::QueryParams::extract",
),
location: (
line: 32,
column: 8,
file: "todo_app_sqlite_pavex/src/blueprint.rs",
),
),
lifecycle: RequestScoped,
cloning_strategy: None,
error_handler: Some((
callable: (
registered_at: "todo_app_sqlite_pavex",
import_path: "pavex::request::query::errors::ExtractQueryParamsError::into_response",
),
location: (
line: 36,
column: 6,
file: "todo_app_sqlite_pavex/src/blueprint.rs",
),
)),
),
(
constructor: (
callable: (
registered_at: "todo_app_sqlite_pavex",
import_path: "pavex::request::route::RouteParams::extract",
),
location: (
line: 41,
column: 8,
file: "todo_app_sqlite_pavex/src/blueprint.rs",
),
),
lifecycle: RequestScoped,
cloning_strategy: None,
error_handler: Some((
callable: (
registered_at: "todo_app_sqlite_pavex",
import_path: "pavex::request::route::errors::ExtractRouteParamsError::into_response",
),
location: (
line: 45,
column: 6,
file: "todo_app_sqlite_pavex/src/blueprint.rs",
),
)),
),
(
constructor: (
callable: (
registered_at: "todo_app_sqlite_pavex",
import_path: "pavex::request::body::JsonBody::extract",
),
location: (
line: 50,
column: 8,
file: "todo_app_sqlite_pavex/src/blueprint.rs",
),
),
lifecycle: RequestScoped,
cloning_strategy: None,
error_handler: Some((
callable: (
registered_at: "todo_app_sqlite_pavex",
import_path: "pavex::request::body::errors::ExtractJsonBodyError::into_response",
),
location: (
line: 54,
column: 6,
file: "todo_app_sqlite_pavex/src/blueprint.rs",
),
)),
),
(
constructor: (
callable: (
registered_at: "todo_app_sqlite_pavex",
import_path: "pavex::request::body::BufferedBody::extract",
),
location: (
line: 57,
column: 8,
file: "todo_app_sqlite_pavex/src/blueprint.rs",
),
),
lifecycle: RequestScoped,
cloning_strategy: None,
error_handler: Some((
callable: (
registered_at: "todo_app_sqlite_pavex",
import_path: "pavex::request::body::errors::ExtractBufferedBodyError::into_response",
),
location: (
line: 61,
column: 6,
file: "todo_app_sqlite_pavex/src/blueprint.rs",
),
)),
),
(
constructor: (
callable: (
registered_at: "todo_app_sqlite_pavex",
import_path: "<pavex::request::body::BodySizeLimit as std::default::Default>::default",
),
location: (
line: 64,
column: 8,
file: "todo_app_sqlite_pavex/src/blueprint.rs",
),
),
lifecycle: RequestScoped,
cloning_strategy: None,
error_handler: None,
),
(
constructor: (
callable: (
registered_at: "todo_app_sqlite_pavex",
import_path: "crate::user_agent::UserAgent::extract",
),
location: (
line: 16,
column: 8,
file: "todo_app_sqlite_pavex/src/blueprint.rs",
),
),
lifecycle: RequestScoped,
cloning_strategy: None,
error_handler: Some((
callable: (
registered_at: "todo_app_sqlite_pavex",
import_path: "crate::user_agent::invalid_user_agent",
),
location: (
line: 20,
column: 6,
file: "todo_app_sqlite_pavex/src/blueprint.rs",
),
)),
),
(
constructor: (
callable: (
registered_at: "todo_app_sqlite_pavex",
import_path: "crate::telemetry::RootSpan::new",
),
location: (
line: 72,
column: 8,
file: "todo_app_sqlite_pavex/src/blueprint.rs",
),
),
lifecycle: RequestScoped,
cloning_strategy: Some(CloneIfNecessary),
error_handler: None,
),
],
middlewares: [
(
middleware: (
callable: (
registered_at: "todo_app_sqlite_pavex",
import_path: "crate::telemetry::logger",
),
location: (
line: 78,
column: 8,
file: "todo_app_sqlite_pavex/src/blueprint.rs",
),
),
error_handler: None,
),
],
routes: [
(
path: "/api/ping",
method_guard: (
inner: Some((
bitset: 256,
extensions: [],
)),
),
request_handler: (
callable: (
registered_at: "todo_app_sqlite_pavex",
import_path: "crate::routes::status::ping",
),
location: (
line: 24,
column: 8,
file: "todo_app_sqlite_pavex/src/blueprint.rs",
),
),
error_handler: None,
),
(
path: "/api/greet/:name",
method_guard: (
inner: Some((
bitset: 256,
extensions: [],
)),
),
request_handler: (
callable: (
registered_at: "todo_app_sqlite_pavex",
import_path: "crate::routes::greet::greet",
),
location: (
line: 25,
column: 8,
file: "todo_app_sqlite_pavex/src/blueprint.rs",
),
),
error_handler: None,
),
],
fallback_request_handler: None,
nested_blueprints: [],
)

View File

@@ -0,0 +1,254 @@
//! Do NOT edit this code.
//! It was automatically generated by Pavex.
//! All manual edits will be lost next time the code is generated.
extern crate alloc;
struct ServerState {
router: matchit::Router<u32>,
#[allow(dead_code)]
application_state: ApplicationState,
}
pub struct ApplicationState {}
pub async fn build_application_state() -> crate::ApplicationState {
crate::ApplicationState {}
}
pub fn run(
server_builder: pavex::server::Server,
application_state: ApplicationState,
) -> pavex::server::ServerHandle {
let server_state = std::sync::Arc::new(ServerState {
router: build_router(),
application_state,
});
server_builder.serve(route_request, server_state)
}
fn build_router() -> matchit::Router<u32> {
let mut router = matchit::Router::new();
router.insert("/api/greet/:name", 0u32).unwrap();
router.insert("/api/ping", 1u32).unwrap();
router
}
async fn route_request(
request: http::Request<hyper::body::Incoming>,
server_state: std::sync::Arc<ServerState>,
) -> pavex::response::Response {
let (request_head, request_body) = request.into_parts();
#[allow(unused)]
let request_body = pavex::request::body::RawIncomingBody::from(request_body);
let request_head: pavex::request::RequestHead = request_head.into();
let matched_route = match server_state.router.at(&request_head.uri.path()) {
Ok(m) => m,
Err(_) => {
let allowed_methods: pavex::router::AllowedMethods = pavex::router::MethodAllowList::from_iter(
vec![],
)
.into();
let matched_route_template = pavex::request::route::MatchedRouteTemplate::new(
"*",
);
return route_2::middleware_0(
matched_route_template,
&allowed_methods,
&request_head,
)
.await;
}
};
let route_id = matched_route.value;
#[allow(unused)]
let url_params: pavex::request::route::RawRouteParams<'_, '_> = matched_route
.params
.into();
match route_id {
0u32 => {
let matched_route_template = pavex::request::route::MatchedRouteTemplate::new(
"/api/greet/:name",
);
match &request_head.method {
&pavex::http::Method::GET => {
route_1::middleware_0(
matched_route_template,
url_params,
&request_head,
)
.await
}
_ => {
let allowed_methods: pavex::router::AllowedMethods = pavex::router::MethodAllowList::from_iter([
pavex::http::Method::GET,
])
.into();
route_2::middleware_0(
matched_route_template,
&allowed_methods,
&request_head,
)
.await
}
}
}
1u32 => {
let matched_route_template = pavex::request::route::MatchedRouteTemplate::new(
"/api/ping",
);
match &request_head.method {
&pavex::http::Method::GET => {
route_0::middleware_0(matched_route_template, &request_head).await
}
_ => {
let allowed_methods: pavex::router::AllowedMethods = pavex::router::MethodAllowList::from_iter([
pavex::http::Method::GET,
])
.into();
route_2::middleware_0(
matched_route_template,
&allowed_methods,
&request_head,
)
.await
}
}
}
i => unreachable!("Unknown route id: {}", i),
}
}
pub mod route_0 {
pub async fn middleware_0(
v0: pavex::request::route::MatchedRouteTemplate,
v1: &pavex::request::RequestHead,
) -> pavex::response::Response {
let v2 = todo_app_sqlite_pavex::telemetry::RootSpan::new(v1, v0);
let v3 = crate::route_0::Next0 {
next: handler,
};
let v4 = pavex::middleware::Next::new(v3);
todo_app_sqlite_pavex::telemetry::logger(v4, v2).await
}
pub async fn handler() -> pavex::response::Response {
let v0 = todo_app_sqlite_pavex::routes::status::ping();
<http::StatusCode as pavex::response::IntoResponse>::into_response(v0)
}
pub struct Next0<T>
where
T: std::future::Future<Output = pavex::response::Response>,
{
next: fn() -> T,
}
impl<T> std::future::IntoFuture for Next0<T>
where
T: std::future::Future<Output = pavex::response::Response>,
{
type Output = pavex::response::Response;
type IntoFuture = T;
fn into_future(self) -> Self::IntoFuture {
(self.next)()
}
}
}
pub mod route_1 {
pub async fn middleware_0(
v0: pavex::request::route::MatchedRouteTemplate,
v1: pavex::request::route::RawRouteParams<'_, '_>,
v2: &pavex::request::RequestHead,
) -> pavex::response::Response {
let v3 = todo_app_sqlite_pavex::telemetry::RootSpan::new(v2, v0);
let v4 = crate::route_1::Next0 {
s_0: v1,
s_1: v2,
next: handler,
};
let v5 = pavex::middleware::Next::new(v4);
todo_app_sqlite_pavex::telemetry::logger(v5, v3).await
}
pub async fn handler(
v0: pavex::request::route::RawRouteParams<'_, '_>,
v1: &pavex::request::RequestHead,
) -> pavex::response::Response {
let v2 = todo_app_sqlite_pavex::user_agent::UserAgent::extract(v1);
let v3 = match v2 {
Ok(ok) => ok,
Err(v3) => {
return {
let v4 = todo_app_sqlite_pavex::user_agent::invalid_user_agent(&v3);
<pavex::response::Response as pavex::response::IntoResponse>::into_response(
v4,
)
};
}
};
let v4 = pavex::request::route::RouteParams::extract(v0);
let v5 = match v4 {
Ok(ok) => ok,
Err(v5) => {
return {
let v6 = pavex::request::route::errors::ExtractRouteParamsError::into_response(
&v5,
);
<pavex::response::Response<
http_body_util::Full<bytes::Bytes>,
> as pavex::response::IntoResponse>::into_response(v6)
};
}
};
let v6 = todo_app_sqlite_pavex::routes::greet::greet(v5, v3);
<pavex::response::Response as pavex::response::IntoResponse>::into_response(v6)
}
pub struct Next0<'a, 'b, 'c, T>
where
T: std::future::Future<Output = pavex::response::Response>,
{
s_0: pavex::request::route::RawRouteParams<'a, 'b>,
s_1: &'c pavex::request::RequestHead,
next: fn(
pavex::request::route::RawRouteParams<'a, 'b>,
&'c pavex::request::RequestHead,
) -> T,
}
impl<'a, 'b, 'c, T> std::future::IntoFuture for Next0<'a, 'b, 'c, T>
where
T: std::future::Future<Output = pavex::response::Response>,
{
type Output = pavex::response::Response;
type IntoFuture = T;
fn into_future(self) -> Self::IntoFuture {
(self.next)(self.s_0, self.s_1)
}
}
}
pub mod route_2 {
pub async fn middleware_0(
v0: pavex::request::route::MatchedRouteTemplate,
v1: &pavex::router::AllowedMethods,
v2: &pavex::request::RequestHead,
) -> pavex::response::Response {
let v3 = todo_app_sqlite_pavex::telemetry::RootSpan::new(v2, v0);
let v4 = crate::route_2::Next0 {
s_0: v1,
next: handler,
};
let v5 = pavex::middleware::Next::new(v4);
todo_app_sqlite_pavex::telemetry::logger(v5, v3).await
}
pub async fn handler(
v0: &pavex::router::AllowedMethods,
) -> pavex::response::Response {
let v1 = pavex::router::default_fallback(v0).await;
<pavex::response::Response as pavex::response::IntoResponse>::into_response(v1)
}
pub struct Next0<'a, T>
where
T: std::future::Future<Output = pavex::response::Response>,
{
s_0: &'a pavex::router::AllowedMethods,
next: fn(&'a pavex::router::AllowedMethods) -> T,
}
impl<'a, T> std::future::IntoFuture for Next0<'a, T>
where
T: std::future::Future<Output = pavex::response::Response>,
{
type Output = pavex::response::Response;
type IntoFuture = T;
fn into_future(self) -> Self::IntoFuture {
(self.next)(self.s_0)
}
}
}

View File

@@ -1,5 +1,5 @@
[package]
name = "todo_app_sqlite_viz"
name = "server_fns_axum"
version = "0.1.0"
edition = "2021"
@@ -7,48 +7,53 @@ edition = "2021"
crate-type = ["cdylib", "rlib"]
[dependencies]
console_log = "1.0.0"
console_error_panic_hook = "0.1.7"
futures = "0.3.25"
cfg-if = "1.0.0"
leptos = { path = "../../leptos" }
leptos_viz = { path = "../../integrations/viz", optional = true }
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"] }
leptos_reactive = { path = "../../leptos_reactive", features = ["nightly"] }
log = "0.4.17"
simple_logger = "4.0.0"
server_fn = { path = "../../server_fn", features = ["serde-lite", "rkyv", "multipart" ]}
log = "0.4"
simple_logger = "4.0"
serde = { version = "1", features = ["derive"] }
viz = { version = "0.4.8", features = ["serve"], optional = true }
tokio = { version = "1.25.0", features = ["full"], optional = true }
http = { version = "0.2.11" }
sqlx = { version = "0.6.2", features = [
"runtime-tokio-rustls",
"sqlite",
], optional = true }
thiserror = "1.0.38"
axum = { version = "0.7", optional = true }
tower = { version = "0.4", optional = true }
tower-http = { version = "0.5", features = ["fs", "tracing", "trace"], optional = true }
tokio = { version = "1", features = ["full"], optional = true }
thiserror = "1.0"
wasm-bindgen = "0.2"
serde_toml = "0.0.1"
toml = "0.8.8"
web-sys = { version = "0.3.67", features = ["FileList", "File"] }
strum = { version = "0.25.0", features = ["strum_macros", "derive"] }
notify = { version = "6.1.1", optional = true }
pin-project-lite = "0.2.13"
[features]
csr = ["leptos/csr", "leptos_meta/csr", "leptos_router/csr"]
hydrate = ["leptos/hydrate", "leptos_meta/hydrate", "leptos_router/hydrate"]
ssr = [
"dep:viz",
"dep:tokio",
"dep:sqlx",
"leptos/ssr",
"leptos_meta/ssr",
"leptos_router/ssr",
"dep:leptos_viz",
"dep:axum",
"dep:tower",
"dep:tower-http",
"dep:tokio",
"leptos/ssr",
"leptos_meta/ssr",
"leptos_router/ssr",
"dep:leptos_axum",
"dep:notify"
]
notify = ["dep:notify"]
[package.metadata.cargo-all-features]
denylist = ["viz", "tokio", "sqlx", "leptos_viz"]
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 = "todo_app_sqlite_viz"
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
@@ -63,7 +68,8 @@ 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"
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

View File

@@ -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", "${@}"]

View File

@@ -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.

View File

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

View File

@@ -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",
"${@}",
]

View File

@@ -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
```

View File

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

View File

@@ -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.

View File

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

View File

@@ -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(())
}

View File

@@ -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(())
}

View File

@@ -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(())
}

View File

@@ -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<Element> {
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<Element> {
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<Element> {
if let Ok(element) =
client.wait().for_element(Locator::Css(".pending")).await
{
return Some(element);
}
None
}
pub async fn todos(client: &Client) -> Vec<Element> {
let todos = client
.find_all(Locator::Css("li"))
.await
.expect("Todo List not found");
todos
}

View File

@@ -0,0 +1,4 @@
pub mod action;
pub mod check;
pub mod find;
pub mod world;

View File

@@ -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(())
}

View File

@@ -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(())
}

View File

@@ -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<Self, anyhow::Error> {
let webdriver_client = build_client().await?;
Ok(Self {
client: webdriver_client,
})
}
}
async fn build_client() -> Result<Client, NewSessionError> {
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)
}

View File

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Some files were not shown because too many files have changed in this diff Show More