mirror of
https://github.com/leptos-rs/leptos.git
synced 2025-12-28 09:02:37 -05:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1bb3169a69 |
48
.github/workflows/check-examples.yml
vendored
48
.github/workflows/check-examples.yml
vendored
@@ -1,48 +0,0 @@
|
||||
name: Test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Test on ${{ matrix.os }} (using rustc ${{ matrix.rust }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
rust:
|
||||
- nightly
|
||||
os:
|
||||
- ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Setup Rust
|
||||
uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
toolchain: ${{ matrix.rust }}
|
||||
override: true
|
||||
components: rustfmt
|
||||
|
||||
- name: Add wasm32-unknown-unknown
|
||||
run: rustup target add wasm32-unknown-unknown
|
||||
|
||||
- name: Setup cargo-make
|
||||
uses: davidB/rust-cargo-make@v1
|
||||
|
||||
- name: Cargo generate-lockfile
|
||||
run: cargo generate-lockfile
|
||||
|
||||
- name: Run Rustfmt
|
||||
run: cargo fmt -- --check
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
|
||||
- name: Run cargo check on all examples
|
||||
run: cargo make check-examples
|
||||
48
.github/workflows/check.yml
vendored
48
.github/workflows/check.yml
vendored
@@ -1,48 +0,0 @@
|
||||
name: Test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Test on ${{ matrix.os }} (using rustc ${{ matrix.rust }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
rust:
|
||||
- nightly
|
||||
os:
|
||||
- ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Setup Rust
|
||||
uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
toolchain: ${{ matrix.rust }}
|
||||
override: true
|
||||
components: rustfmt
|
||||
|
||||
- name: Add wasm32-unknown-unknown
|
||||
run: rustup target add wasm32-unknown-unknown
|
||||
|
||||
- name: Setup cargo-make
|
||||
uses: davidB/rust-cargo-make@v1
|
||||
|
||||
- name: Cargo generate-lockfile
|
||||
run: cargo generate-lockfile
|
||||
|
||||
- name: Run Rustfmt
|
||||
run: cargo fmt -- --check
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
|
||||
- name: Run cargo check on all libraries
|
||||
run: cargo make check
|
||||
2
.github/workflows/test.yml
vendored
2
.github/workflows/test.yml
vendored
@@ -45,4 +45,4 @@ jobs:
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
|
||||
- name: Run tests with all features
|
||||
run: cargo make test
|
||||
run: cargo make ci
|
||||
|
||||
28
Cargo.toml
28
Cargo.toml
@@ -4,13 +4,9 @@ members = [
|
||||
"leptos",
|
||||
"leptos_dom",
|
||||
"leptos_config",
|
||||
"leptos_hot_reload",
|
||||
"leptos_macro",
|
||||
"leptos_reactive",
|
||||
"leptos_server",
|
||||
"server_fn",
|
||||
"server_fn_macro",
|
||||
"server_fn/server_fn_macro_default",
|
||||
|
||||
# integrations
|
||||
"integrations/actix",
|
||||
@@ -25,22 +21,18 @@ members = [
|
||||
exclude = ["benchmarks", "examples"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.2.2"
|
||||
version = "0.2.0-beta"
|
||||
|
||||
[workspace.dependencies]
|
||||
leptos = { path = "./leptos", default-features = false, version = "0.2.2" }
|
||||
leptos_dom = { path = "./leptos_dom", default-features = false, version = "0.2.2" }
|
||||
leptos_hot_reload = { path = "./leptos_hot_reload", version = "0.2.2" }
|
||||
leptos_macro = { path = "./leptos_macro", default-features = false, version = "0.2.2" }
|
||||
leptos_reactive = { path = "./leptos_reactive", default-features = false, version = "0.2.2" }
|
||||
leptos_server = { path = "./leptos_server", default-features = false, version = "0.2.2" }
|
||||
server_fn = { path = "./server_fn", default-features = false, version = "0.2.2" }
|
||||
server_fn_macro = { path = "./server_fn_macro", default-features = false, version = "0.2.2" }
|
||||
server_fn_macro_default = { path = "./server_fn/server_fn_macro_default", default-features = false, version = "0.2.2" }
|
||||
leptos_config = { path = "./leptos_config", default-features = false, version = "0.2.2" }
|
||||
leptos_router = { path = "./router", version = "0.2.2" }
|
||||
leptos_meta = { path = "./meta", default-feature = false, version = "0.2.2" }
|
||||
leptos_integration_utils = { path = "./integrations/utils", version = "0.2.2" }
|
||||
leptos = { path = "./leptos", default-features = false, version = "0.2.0-beta" }
|
||||
leptos_dom = { path = "./leptos_dom", default-features = false, version = "0.2.0-beta" }
|
||||
leptos_macro = { path = "./leptos_macro", default-features = false, version = "0.2.0-beta" }
|
||||
leptos_reactive = { path = "./leptos_reactive", default-features = false, version = "0.2.0-beta" }
|
||||
leptos_server = { path = "./leptos_server", default-features = false, version = "0.2.0-beta" }
|
||||
leptos_config = { path = "./leptos_config", default-features = false, version = "0.2.0-beta" }
|
||||
leptos_router = { path = "./router", version = "0.2.0-beta" }
|
||||
leptos_meta = { path = "./meta", default-feature = false, version = "0.2.0-beta" }
|
||||
leptos_integration_utils = { path = "./integrations/utils", version = "0.2.0-beta" }
|
||||
|
||||
[profile.release]
|
||||
codegen-units = 1
|
||||
|
||||
@@ -8,20 +8,20 @@
|
||||
default_to_workspace = false
|
||||
|
||||
[tasks.ci]
|
||||
dependencies = ["check", "check-examples", "test"]
|
||||
dependencies = ["build", "check-examples", "test"]
|
||||
|
||||
[tasks.check]
|
||||
[tasks.build]
|
||||
clear = true
|
||||
dependencies = ["check-all", "check-wasm"]
|
||||
dependencies = ["build-all", "build-wasm"]
|
||||
|
||||
[tasks.check-all]
|
||||
[tasks.build-all]
|
||||
command = "cargo"
|
||||
args = ["+nightly", "check-all-features"]
|
||||
args = ["+nightly", "build-all-features"]
|
||||
install_crate = "cargo-all-features"
|
||||
|
||||
[tasks.check-wasm]
|
||||
[tasks.build-wasm]
|
||||
clear = true
|
||||
dependencies = [{ name = "check-wasm", path = "leptos" }]
|
||||
dependencies = [{ name = "build-wasm", path = "leptos" }]
|
||||
|
||||
[tasks.check-examples]
|
||||
clear = true
|
||||
@@ -31,17 +31,12 @@ dependencies = [
|
||||
{ name = "check", path = "examples/counter_without_macros" },
|
||||
{ name = "check", path = "examples/counters" },
|
||||
{ name = "check", path = "examples/counters_stable" },
|
||||
{ name = "check", path = "examples/error_boundary" },
|
||||
{ name = "check", path = "examples/errors_axum" },
|
||||
{ name = "check", path = "examples/fetch" },
|
||||
{ name = "check", path = "examples/hackernews" },
|
||||
{ name = "check", path = "examples/hackernews_axum" },
|
||||
{ name = "check", path = "examples/login_with_token_csr_only" },
|
||||
{ name = "check", path = "examples/parent_child" },
|
||||
{ name = "check", path = "examples/router" },
|
||||
{ name = "check", path = "examples/session_auth_axum" },
|
||||
{ name = "check", path = "examples/ssr_modes" },
|
||||
{ name = "check", path = "examples/ssr_modes_axum" },
|
||||
{ name = "check", path = "examples/tailwind" },
|
||||
{ name = "check", path = "examples/todo_app_sqlite" },
|
||||
{ name = "check", path = "examples/todo_app_sqlite_axum" },
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
# Getting Started
|
||||
|
||||
There are two basic paths to getting started with Leptos:
|
||||
|
||||
1. Client-side rendering with [Trunk](https://trunkrs.dev/)
|
||||
2. Full-stack rendering with [`cargo-leptos`](https://github.com/leptos-rs/cargo-leptos)
|
||||
|
||||
For the early examples, it will be easiest to begin with Trunk. We’ll introduce
|
||||
`cargo-leptos` a little later in this series.
|
||||
|
||||
|
||||
If you don’t already have it installed, you can install Trunk by running
|
||||
|
||||
```bash
|
||||
@@ -20,22 +20,12 @@ Create a basic Rust binary project
|
||||
cargo init leptos-tutorial
|
||||
```
|
||||
|
||||
> We recommend using `nightly` Rust, as it enables [a few nice features](https://github.com/leptos-rs/leptos#nightly-note). To use `nightly` Rust with WebAssembly, you can run
|
||||
>
|
||||
> ```bash
|
||||
> rustup toolchain install nightly
|
||||
> rustup default nightly
|
||||
> rustup target add wasm32-unknown-unknown
|
||||
> ```
|
||||
|
||||
`cd` into your new `leptos-tutorial` project and add `leptos` as a dependency
|
||||
|
||||
`cd` into your new `leptos-tutorial` project and add `leptos` as a dependency
|
||||
```bash
|
||||
cargo add leptos
|
||||
```
|
||||
|
||||
Create a simple `index.html` in the root of the `leptos-tutorial` directory
|
||||
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
@@ -45,26 +35,14 @@ Create a simple `index.html` in the root of the `leptos-tutorial` directory
|
||||
```
|
||||
|
||||
And add a simple “Hello, world!” to your `main.rs`
|
||||
|
||||
```rust
|
||||
```rust
|
||||
use leptos::*;
|
||||
|
||||
fn main() {
|
||||
mount_to_body(|cx| view! { cx, <p>"Hello, world!"</p> })
|
||||
mount_to_body(|_cx| view! { cx, <p>"Hello, world!"</p> })
|
||||
}
|
||||
```
|
||||
|
||||
Your directory structure should now look something like this
|
||||
|
||||
```
|
||||
leptos_tutorial
|
||||
├── src
|
||||
│ └── main.rs
|
||||
├── Cargo.html
|
||||
├── index.html
|
||||
```
|
||||
|
||||
Now run `trunk serve --open` from the root of the `leptos-tutorial` directory.
|
||||
Trunk should automatically compile your app and open it in your default browser.
|
||||
If you make edits to `main.rs`, Trunk will recompile your source code and
|
||||
live-reload the page.
|
||||
Now run `trunk serve --open`. Trunk should automatically compile your app and
|
||||
open it in your default browser. If you make edits to `main.rs`, Trunk will
|
||||
recompile your source code and live-reload the page.
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
# Responding to Changes with `create_effect`
|
||||
|
||||
Believe it or not, we’ve made it this far without having mentioned half of the reactive system: effects.
|
||||
|
||||
Leptos is built on a fine-grained reactive system, which means that individual reactive values (“signals,” sometimes known as observables) trigger rerunning the code that reacts to them (“effects,” sometimes known as observers). These two halves of the reactive system are inter-dependent. Without effects, signals can change within the reactive system but never be observed in a way that interacts with the outside world. Without signals, effects run once but never again, as there’s no observable value to subscribe to.
|
||||
|
||||
[`create_effect`](https://docs.rs/leptos_reactive/latest/leptos_reactive/fn.create_effect.html) takes a function as its argument. It immediately runs the function. If you access any reactive signal inside that function, it registers the fact that the effect depends on that signal with the reactive runtime. Whenever one of the signals that the effect depends on changes, the effect runs again.
|
||||
|
||||
```rust
|
||||
let (a, set_a) = create_signal(cx, 0);
|
||||
let (b, set_b) = create_signal(cx, 0);
|
||||
|
||||
create_effect(cx, move |_| {
|
||||
// immediately prints "Value: 0" and subscribes to `a`
|
||||
log::debug!("Value: {}", a());
|
||||
});
|
||||
```
|
||||
|
||||
The effect function is called with an argument containing whatever value it returned the last time it ran. On the initial run, this is `None`.
|
||||
|
||||
By default, effects **do not run on the server**. This means you can call browser-specific APIs within the effect function without causing issues. If you need an effect to run on the server, use [`create_isomorphic_effect`](https://docs.rs/leptos_reactive/latest/leptos_reactive/fn.create_isomorphic_effect.html).
|
||||
|
||||
## Autotracking and Dynamic Dependencies
|
||||
|
||||
If you’re familiar with a framework like React, you might notice one key difference. React and similar frameworks typically require you to pass a “dependency array,” an explicit set of variables that determine when the effect should rerun.
|
||||
|
||||
Because Leptos comes from the tradition of synchronous reactive programming, we don’t need this explicit dependency list. Instead, we automatically track dependencies depending on which signals are accessed within the effect.
|
||||
|
||||
This has two effects (no pun intended). Dependencies are
|
||||
|
||||
1. **Automatic**: You don’t need to maintain a dependency list, or worry about what should or shouldn’t be included. The framework simply tracks which signals might cause the effect to rerun, and handles it for you.
|
||||
2. **Dynamic**: The dependency list is cleared and updated every time the effect runs. If your effect contains a conditional (for example), only signals that are used in the current branch are tracked. This means that effects rerun the absolute minimum number of times.
|
||||
|
||||
> If this sounds like magic, and if you want a deep dive into how automatic dependency tracking works, [check out this video](https://www.youtube.com/watch?v=GWB3vTWeLd4). (Apologies for the low volume!)
|
||||
|
||||
## Effects as Zero-Cost-ish Abstraction
|
||||
|
||||
While they’re not a “zero-cost abstraction” in the most technical sense—they require some additional memory use, exist at runtime, etc.—at a higher level, from the perspective of whatever expensive API calls or other work you’re doing within them, effects are a zero-cost abstraction. They rerun the absolute minimum number of times necessary, given how you’ve described them.
|
||||
|
||||
Imagine that I’m creating some kind of chat software, and I want people to be able to display their full name, or just their first name, and to notify the server whenever their name changes:
|
||||
|
||||
```rust
|
||||
let (first, set_first) = create_signal(cx, String::new());
|
||||
let (last, set_last) = create_signal(cx, String::new());
|
||||
let (use_last, set_use_last) = create_signal(cx, true);
|
||||
|
||||
// this will add the name to the log
|
||||
// any time one of the source signals changes
|
||||
create_effect(cx, move |_| {
|
||||
log(
|
||||
cx,
|
||||
if use_last() {
|
||||
format!("{} {}", first(), last())
|
||||
} else {
|
||||
first()
|
||||
},
|
||||
)
|
||||
});
|
||||
```
|
||||
|
||||
If `use_last` is `true`, effect should rerun whenever `first`, `last`, or `use_last` changes. But if I toggle `use_last` to `false`, a change in `last` will never cause the full name to change. In fact, `last` will be removed from the dependency list until `use_last` toggles again. This saves us from sending multiple unnecessary requests to the API if I change `last` multiple times while `use_last` is still `false`.
|
||||
|
||||
## To `create_effect`, or not to `create_effect`?
|
||||
|
||||
Effects are intended to run _side-effects_ of the system, not to synchronize state _within_ the system. In other words: don’t write to signals within effects.
|
||||
|
||||
If you need to define a signal that depends on the value of other signals, use a derived signal or [`create_memo`](https://docs.rs/leptos_reactive/latest/leptos_reactive/fn.create_memo.html).
|
||||
|
||||
If you need to synchronize some reactive value with the non-reactive world outside—like a web API, the console, the filesystem, or the DOM—create an effect.
|
||||
|
||||
> If you’re curious for more information about when you should and shouldn’t use `create_effect`, [check out this video](https://www.youtube.com/watch?v=aQOFJQ2JkvQ) for a more in-depth consideration!
|
||||
|
||||
## Effects and Rendering
|
||||
|
||||
We’ve managed to get this far without mentioning effects because they’re built into the Leptos DOM renderer. We’ve seen that you can create a signal and pass it into the `view` macro, and it will update the relevant DOM node whenever the signal changes:
|
||||
|
||||
```rust
|
||||
let (count, set_count) = create_signal(cx, 0);
|
||||
|
||||
view! { cx,
|
||||
<p>{count}</p>
|
||||
}
|
||||
```
|
||||
|
||||
This works because the framework essentially creates an effect wrapping this update. You can imagine Leptos translating this view into something like this:
|
||||
|
||||
```rust
|
||||
let (count, set_count) = create_signal(cx, 0);
|
||||
|
||||
// create a DOM element
|
||||
let p = create_element("p");
|
||||
|
||||
// create an effect to reactively update the text
|
||||
create_effect(cx, move |prev_value| {
|
||||
// first, access the signal’s value and convert it to a string
|
||||
let text = count().to_string();
|
||||
|
||||
// if this is different from the previous value, update the node
|
||||
if prev_value != Some(text) {
|
||||
p.set_text_content(&text);
|
||||
}
|
||||
|
||||
// return this value so we can memoize the next update
|
||||
text
|
||||
});
|
||||
```
|
||||
|
||||
Every time `count` is updated, this effect wil rerun. This is what allows reactive, fine-grained updates to the DOM.
|
||||
|
||||
<iframe src="https://codesandbox.io/p/sandbox/serene-thompson-40974n?file=%2Fsrc%2Fmain.rs&selection=%5B%7B%22endColumn%22%3A1%2C%22endLineNumber%22%3A2%2C%22startColumn%22%3A1%2C%22startLineNumber%22%3A2%7D%5D" width="100%" height="1000px"></iframe>
|
||||
@@ -1,171 +0,0 @@
|
||||
# Global State Management
|
||||
|
||||
So far, we've only been working with local state in components
|
||||
We've only seen how to communicate between parent and child components
|
||||
But there are also more general ways to manage global state
|
||||
|
||||
The three best approaches to global state are
|
||||
|
||||
1. Using the router to drive global state via the URL
|
||||
2. Passing signals through context
|
||||
3. Creating a global state struct and creating lenses into it with `create_slice`
|
||||
|
||||
## Option #1: URL as Global State
|
||||
|
||||
The next few sections of the tutorial will be about the router.
|
||||
So for now, we'll just look at options #2 and #3.
|
||||
|
||||
## Option #2: Passing Signals through Context
|
||||
|
||||
In virtual DOM libraries like React, using the Context API to manage global
|
||||
state is a bad idea: because the entire app exists in a tree, changing
|
||||
some value provided high up in the tree can cause the whole app to render.
|
||||
|
||||
In fine-grained reactive libraries like Leptos, this is simply not the case.
|
||||
You can create a signal in the root of your app and pass it down to other
|
||||
components using provide_context(). Changing it will only cause rerendering
|
||||
in the specific places it is actually used, not the whole app.
|
||||
|
||||
We start by creating a signal in the root of the app and providing it to
|
||||
all its children and descendants using `provide_context`.
|
||||
|
||||
```rust
|
||||
#[component]
|
||||
fn App(cx: Scope) -> impl IntoView {
|
||||
// here we create a signal in the root that can be consumed
|
||||
// anywhere in the app.
|
||||
let (count, set_count) = create_signal(cx, 0);
|
||||
// we'll pass the setter to specific components,
|
||||
// but provide the count itself to the whole app via context
|
||||
provide_context(cx, count);
|
||||
|
||||
view! { cx,
|
||||
// SetterButton is allowed to modify the count
|
||||
<SetterButton set_count/>
|
||||
// These consumers can only read from it
|
||||
// But we could give them write access by passing `set_count` if we wanted
|
||||
<FancyMath/>
|
||||
<ListItems/>
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`<SetterButton/>` is the kind of counter we’ve written several times now.
|
||||
(See the sandbox below if you don’t understand what I mean.)
|
||||
|
||||
`<FancyMath/>` and `<ListItems/>` both consume the signal we’re providing via
|
||||
`use_context` and do something with it.
|
||||
|
||||
```rust
|
||||
/// A component that does some "fancy" math with the global count
|
||||
#[component]
|
||||
fn FancyMath(cx: Scope) -> impl IntoView {
|
||||
// here we consume the global count signal with `use_context`
|
||||
let count = use_context::<ReadSignal<u32>>(cx)
|
||||
// we know we just provided this in the parent component
|
||||
.expect("there to be a `count` signal provided");
|
||||
let is_even = move || count() & 1 == 0;
|
||||
|
||||
view! { cx,
|
||||
<div class="consumer blue">
|
||||
"The number "
|
||||
<strong>{count}</strong>
|
||||
{move || if is_even() {
|
||||
" is"
|
||||
} else {
|
||||
" is not"
|
||||
}}
|
||||
" even."
|
||||
</div>
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This kind of “provide a signal in a parent, consume it in a child” should be familiar
|
||||
from the chapter on [parent-child interactions](./view/08_parent_child.md). The same
|
||||
pattern you use to communicate between parents and children works for grandparents and
|
||||
grandchildren, or any ancestors and descendents: in other words, between “global” state
|
||||
in the root component of your app and any other components anywhere else in the app.
|
||||
|
||||
Because of the fine-grained nature of updates, this is usually all you need. However,
|
||||
in some cases with more complex state changes, you may want to use a slightly more
|
||||
structured approach to global state.
|
||||
|
||||
## Option #3: Create a Global State Struct
|
||||
|
||||
You can use this approach to build a single global data structure
|
||||
that holds the state for your whole app, and then access it by
|
||||
taking fine-grained slices using
|
||||
[`create_slice`](https://docs.rs/leptos/latest/leptos/fn.create_slice.html)
|
||||
or [`create_memo`](https://docs.rs/leptos/latest/leptos/fn.create_memo.html),
|
||||
so that changing one part of the state doesn't cause parts of your
|
||||
app that depend on other parts of the state to change.
|
||||
|
||||
You can begin by defining a simple state struct:
|
||||
|
||||
```rust
|
||||
#[derive(Default, Clone, Debug)]
|
||||
struct GlobalState {
|
||||
count: u32,
|
||||
name: String,
|
||||
}
|
||||
```
|
||||
|
||||
Provide it in the root of your app so it’s available everywhere.
|
||||
|
||||
```rust
|
||||
#[component]
|
||||
fn App(cx: Scope) -> impl IntoView {
|
||||
// we'll provide a single signal that holds the whole state
|
||||
// each component will be responsible for creating its own "lens" into it
|
||||
let state = create_rw_signal(cx, GlobalState::default());
|
||||
provide_context(cx, state);
|
||||
|
||||
// ...
|
||||
```
|
||||
|
||||
Then child components can access “slices” of that state with fine-grained
|
||||
updates via `create_slice`. Each slice signal only updates when the particular
|
||||
piece of the larger struct it accesses updates. This means you can create a single
|
||||
root signal, and then take independent, fine-grained slices of it in different
|
||||
components, each of which can update without notifying the others of changes.
|
||||
|
||||
```rust
|
||||
/// A component that updates the count in the global state.
|
||||
#[component]
|
||||
fn GlobalStateCounter(cx: Scope) -> impl IntoView {
|
||||
let state = use_context::<RwSignal<GlobalState>>(cx).expect("state to have been provided");
|
||||
|
||||
// `create_slice` lets us create a "lens" into the data
|
||||
let (count, set_count) = create_slice(
|
||||
cx,
|
||||
// we take a slice *from* `state`
|
||||
state,
|
||||
// our getter returns a "slice" of the data
|
||||
|state| state.count,
|
||||
// our setter describes how to mutate that slice, given a new value
|
||||
|state, n| state.count = n,
|
||||
);
|
||||
|
||||
view! { cx,
|
||||
<div class="consumer blue">
|
||||
<button
|
||||
on:click=move |_| {
|
||||
set_count(count() + 1);
|
||||
}
|
||||
>
|
||||
"Increment Global Count"
|
||||
</button>
|
||||
<br/>
|
||||
<span>"Count is: " {count}</span>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Clicking this button only updates `state.count`, so if we create another slice
|
||||
somewhere else that only takes `state.name`, clicking the button won’t cause
|
||||
that other slice to update. This allows you to combine the benefits of a top-down
|
||||
data flow and of fine-grained reactive updates.
|
||||
|
||||
<iframe src="https://codesandbox.io/p/sandbox/1-basic-component-forked-8bte19?selection=%5B%7B%22endColumn%22%3A1%2C%22endLineNumber%22%3A2%2C%22startColumn%22%3A1%2C%22startLineNumber%22%3A2%7D%5D&file=%2Fsrc%2Fmain.rs" width="100%" height="1000px">
|
||||
@@ -17,16 +17,15 @@
|
||||
- [Async](./async/README.md)
|
||||
- [Loading Data with Resources](./async/10_resources.md)
|
||||
- [Suspense](./async/11_suspense.md)
|
||||
- [Transition](./async/12_transition.md)
|
||||
- [Actions](./async/13_actions.md)
|
||||
- [Responding to Changes with `create_effect`](./14_create_effect.md)
|
||||
- [Global State Management](./15_global_state.md)
|
||||
- [Transition]()
|
||||
- [Interlude: Styling — CSS, Tailwind, Style.rs, and more]()
|
||||
- [State Management]()
|
||||
- [Interlude: Advanced Reactivity]()
|
||||
- [Router]()
|
||||
- [Fundamentals]()
|
||||
- [defining `<Routes/>`]()
|
||||
- [`<A/>`]()
|
||||
- [`<Form/>`]()
|
||||
- [Interlude: Styling — CSS, Tailwind, Style.rs, and more]()
|
||||
- [Metadata]()
|
||||
- [SSR]()
|
||||
- [Models of SSR]()
|
||||
@@ -40,4 +39,3 @@
|
||||
- [Forms]()
|
||||
- [`<ActionForm/>`s]()
|
||||
- [Turning off WebAssembly]()
|
||||
- [Advanced Reactivity]()
|
||||
|
||||
@@ -15,12 +15,12 @@ let (count, set_count) = create_signal(cx, 0);
|
||||
|
||||
// our resource
|
||||
let async_data = create_resource(cx,
|
||||
count,
|
||||
// every time `count` changes, this will run
|
||||
|value| async move {
|
||||
log!("loading data from API");
|
||||
load_data(value).await
|
||||
},
|
||||
count,
|
||||
// every time `count` changes, this will run
|
||||
|value| async move {
|
||||
log!("loading data from API");
|
||||
load_data(value).await
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
@@ -40,14 +40,14 @@ So, you can show the current state of a resource in your view:
|
||||
```rust
|
||||
let once = create_resource(cx, || (), |_| async move { load_data().await });
|
||||
view! { cx,
|
||||
<h1>"My Data"</h1>
|
||||
{move || match once.read(cx) {
|
||||
None => view! { cx, <p>"Loading..."</p> }.into_view(cx),
|
||||
Some(data) => view! { cx, <ShowData data/> }.into_view(cx)
|
||||
}}
|
||||
<h1>"My Data"</h1>
|
||||
{move || match once.read(cx) {
|
||||
None => view! { cx, <p>"Loading..."</p> }.into_view(cx),
|
||||
Some(data) => view! { cx, <ShowData data/> }.into_view(cx)
|
||||
}}
|
||||
}
|
||||
```
|
||||
|
||||
Resources also provide a `refetch()` method that allows you to manually reload the data (for example, in response to a button click) and a `loading()` method that returns a `ReadSignal<bool>` indicating whether the resource is currently loading or not.
|
||||
Resources also provide a `refetch()` method that allow you to manually reload the data (for example, in response to a button click) and a `loading()` method that returns a `ReadSignal<bool>` indicating whether the resource is currently loading or not.
|
||||
|
||||
<iframe src="https://codesandbox.io/p/sandbox/10-async-resources-4z0qt3?file=%2Fsrc%2Fmain.rs&selection=%5B%7B%22endColumn%22%3A1%2C%22endLineNumber%22%3A3%2C%22startColumn%22%3A1%2C%22startLineNumber%22%3A3%7D%5D" width="100%" height="1000px"></iframe>
|
||||
|
||||
@@ -7,11 +7,11 @@ let (count, set_count) = create_signal(cx, 0);
|
||||
let a = create_resource(cx, count, |count| async move { load_a(count).await });
|
||||
|
||||
view! { cx,
|
||||
<h1>"My Data"</h1>
|
||||
{move || match once.read(cx) {
|
||||
None => view! { cx, <p>"Loading..."</p> }.into_view(cx),
|
||||
Some(data) => view! { cx, <ShowData data/> }.into_view(cx)
|
||||
}}
|
||||
<h1>"My Data"</h1>
|
||||
{move || match once.read(cx) {
|
||||
None => view! { cx, <p>"Loading..."</p> }.into_view(cx),
|
||||
Some(data) => view! { cx, <ShowData data/> }.into_view(cx)
|
||||
}}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -24,14 +24,14 @@ let a = create_resource(cx, count, |count| async move { load_a(count).await });
|
||||
let b = create_resource(cx, count2, |count| async move { load_b(count).await });
|
||||
|
||||
view! { cx,
|
||||
<h1>"My Data"</h1>
|
||||
{move || match (a.read(cx), b.read(cx)) {
|
||||
_ => view! { cx, <p>"Loading..."</p> }.into_view(cx),
|
||||
(Some(a), Some(b)) => view! { cx,
|
||||
<ShowA a/>
|
||||
<ShowA b/>
|
||||
}.into_view(cx)
|
||||
}}
|
||||
<h1>"My Data"</h1>
|
||||
{move || match (a.read(cx), b.read(cx)) {
|
||||
_ => view! { cx, <p>"Loading..."</p> }.into_view(cx),
|
||||
(Some(a), Some(b)) => view! { cx,
|
||||
<ShowA a/>
|
||||
<ShowA b/>
|
||||
}.into_view(cx)
|
||||
}}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -46,22 +46,22 @@ let a = create_resource(cx, count, |count| async move { load_a(count).await });
|
||||
let b = create_resource(cx, count2, |count| async move { load_b(count).await });
|
||||
|
||||
view! { cx,
|
||||
<h1>"My Data"</h1>
|
||||
<Suspense
|
||||
fallback=move || view! { cx, <p>"Loading..."</p> }
|
||||
>
|
||||
<h2>"My Data"</h2>
|
||||
<h3>"A"</h3>
|
||||
{move || {
|
||||
a.read(cx)
|
||||
.map(|a| view! { cx, <ShowA a/> })
|
||||
}}
|
||||
<h3>"B"</h3>
|
||||
{move || {
|
||||
b.read(cx)
|
||||
.map(|b| view! { cx, <ShowB b/> })
|
||||
}}
|
||||
</Suspense>
|
||||
<h1>"My Data"</h1>
|
||||
<Suspense
|
||||
fallback=move || view! { cx, <p>"Loading..."</p> }
|
||||
>
|
||||
<h2>"My Data"</h2>
|
||||
<h3>"A"</h3>
|
||||
{move || {
|
||||
a.read(cx)
|
||||
.map(|a| view! { cx, <ShowA a/> })
|
||||
}}
|
||||
<h3>"B"</h3>
|
||||
{move || {
|
||||
b.read(cx)
|
||||
.map(|b| view! { cx, <ShowB b/> })
|
||||
}}
|
||||
</Suspense>
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
# Mutating Data with Actions
|
||||
|
||||
We’ve talked about how to load `async` data with resources. Resources immediately load data and work closely with `<Suspense/>` and `<Transition/>` components to show whether data is loading in your app. But what if you just want to call some arbitrary `async` function and keep track of what it’s doing?
|
||||
|
||||
Well, you could always use [`spawn_local`](https://docs.rs/leptos/latest/leptos/fn.spawn_local.html). This allows you to just spawn an `async` task in a synchronous environment by handing the `Future` off to the browser (or, on the server, Tokio or whatever other runtime you’re using). But how do you know if it’s still pending? Well, you could just set a signal to show whether it’s loading, and another one to show the result...
|
||||
|
||||
All of this is true. Or you could use the final `async` primitive: [`create_action`](https://docs.rs/leptos/latest/leptos/fn.create_action.html).
|
||||
|
||||
Actions and resources seem similar, but they represent fundamentally different things. If you’re trying to load data by running an `async` function, either once or when some other value changes, you probably want to use `create_resource`. If you’re trying to occasionally run an `async` function in response to something like a user clicking a button, you probably want to use `create_action`.
|
||||
|
||||
Say we have some `async` function we want to run.
|
||||
|
||||
```rust
|
||||
async fn add_todo(new_title: &str) -> Uuid {
|
||||
/* do some stuff on the server to add a new todo */
|
||||
}
|
||||
```
|
||||
|
||||
`create_action` takes a reactive `Scope` and an `async` function that takes a reference to a single argument, which you could think of as its “input type.”
|
||||
|
||||
> The input is always a single type. If you want to pass in multiple arguments, you can do it with a struct or tuple.
|
||||
>
|
||||
> ```rust
|
||||
> // if there's a single argument, just use that
|
||||
> let action1 = create_action(cx, |input: &String| {
|
||||
> let input = input.clone();
|
||||
> async move { todo!() }
|
||||
> });
|
||||
>
|
||||
> // if there are no arguments, use the unit type `()`
|
||||
> let action2 = create_action(cx, |input: &()| async { todo!() });
|
||||
>
|
||||
> // if there are multiple arguments, use a tuple
|
||||
> let action3 = create_action(cx,
|
||||
> |input: &(usize, String)| async { todo!() }
|
||||
> );
|
||||
> ```
|
||||
>
|
||||
> Because the action function takes a reference but the `Future` needs to have a `'static` lifetime, you’ll usually need to clone the value to pass it into the `Future`. This is admittedly awkward but it unlocks some powerful features like optimistic UI. We’ll see a little more about that in future chapters.
|
||||
|
||||
So in this case, all we need to do to create an action is
|
||||
|
||||
```rust
|
||||
let add_todo = create_action(cx, |input: &String| {
|
||||
let input = input.to_owned();
|
||||
async move { add_todo(&input).await }
|
||||
});
|
||||
```
|
||||
|
||||
Rather than calling `add_todo` directly, we’ll call it with `.dispatch()`, as in
|
||||
|
||||
```rust
|
||||
add_todo.dispatch("Some value".to_string());
|
||||
```
|
||||
|
||||
You can do this from an event listener, a timeout, or anywhere; because `.dispatch()` isn’t an `async` function, it can be called from a synchronous context.
|
||||
|
||||
Actions provide access to a few signals that synchronize between the asynchronous action you’re calling and the synchronous reactive system:
|
||||
|
||||
```rust
|
||||
let submitted = add_todo.input(); // RwSignal<Option<String>>
|
||||
let pending = add_todo.pending(); // ReadSignal<bool>
|
||||
let todo_id = add_todo.value(); // RwSignal<Option<Uuid>>
|
||||
```
|
||||
|
||||
This makes it easy to track the current state of your request, show a loading indicator, or do “optimistic UI” based on the assumption that the submission will succeed.
|
||||
|
||||
```rust
|
||||
let input_ref = create_node_ref::<Input>(cx);
|
||||
|
||||
view! { cx,
|
||||
<form
|
||||
on:submit=move |ev| {
|
||||
ev.prevent_default(); // don't reload the page...
|
||||
let input = input_ref.get().expect("input to exist");
|
||||
add_todo.dispatch(input.value());
|
||||
}
|
||||
>
|
||||
<label>
|
||||
"What do you need to do?"
|
||||
<input type="text"
|
||||
node_ref=input_ref
|
||||
/>
|
||||
</label>
|
||||
<button type="submit">"Add Todo"</button>
|
||||
</form>
|
||||
// use our loading state
|
||||
<p>{move || pending().then("Loading...")}</p>
|
||||
}
|
||||
```
|
||||
|
||||
Now, there’s a chance this all seems a little over-complicated, or maybe too restricted. I wanted to include actions here, alongside resources, as the missing piece of the puzzle. In a real Leptos app, you’ll actually most often use actions alongside server functions, [`create_server_action`](https://docs.rs/leptos/latest/leptos/fn.create_server_action.html), and the [`<ActionForm/>`](https://docs.rs/leptos_router/latest/leptos_router/fn.ActionForm.html) component to create really powerful progressively-enhanced forms. So if this primitive seems useless to you... Don’t worry! Maybe it will make sense later. (Or check out our [`todo_app_sqlite`](https://github.com/leptos-rs/leptos/blob/main/examples/todo_app_sqlite/src/todo.rs) example now.)
|
||||
|
||||
<iframe src="https://codesandbox.io/p/sandbox/10-async-resources-forked-hgpfp0?selection=%5B%7B%22endColumn%22%3A1%2C%22endLineNumber%22%3A4%2C%22startColumn%22%3A1%2C%22startLineNumber%22%3A4%7D%5D&file=%2Fsrc%2Fmain.rs" width="100%" height="1000px"></iframe>
|
||||
@@ -12,19 +12,19 @@ let (count, set_count) = create_signal(cx, 0);
|
||||
let double_count = move || count() * 2;
|
||||
let count_is_odd = move || count() & 1 == 1;
|
||||
let text = move || if count_is_odd() {
|
||||
"odd"
|
||||
"odd"
|
||||
} else {
|
||||
"even"
|
||||
"even"
|
||||
};
|
||||
|
||||
// an effect automatically tracks the signals it depends on
|
||||
// and reruns when they change
|
||||
// and re-runs when they change
|
||||
create_effect(cx, move |_| {
|
||||
log!("text = {}", text());
|
||||
log!("text = {}", text());
|
||||
});
|
||||
|
||||
view! { cx,
|
||||
<p>{move || text().to_uppercase()}</p>
|
||||
<p>{move || text().to_uppercase()}</p>
|
||||
}
|
||||
```
|
||||
|
||||
@@ -45,7 +45,7 @@ The key phrase here is “runs some kind of code.” The natural way to “run s
|
||||
|
||||
1. virtual DOM (VDOM) frameworks like React, Yew, or Dioxus rerun a component or render function over and over, to generate a virtual DOM tree that can be reconciled with the previous result to patch the DOM
|
||||
2. compiled frameworks like Angular and Svelte divide your component templates into “create” and “update” functions, rerunning the update function when they detect a change to the component’s state
|
||||
3. in fine-grained reactive frameworks like SolidJS, Sycamore, or Leptos, _you_ define the functions that rerun
|
||||
3. in fine-grained reactive frameworks like SolidJS, Sycamore, or Leptos, _you_ define the functions that re-run
|
||||
|
||||
That’s what all our components are doing.
|
||||
|
||||
@@ -59,16 +59,16 @@ pub fn SimpleCounter(cx: Scope) -> impl IntoView {
|
||||
let increment = move |_| set_value.update(|value| *value += 1);
|
||||
|
||||
view! { cx,
|
||||
<button on:click=increment>
|
||||
{value}
|
||||
</button>
|
||||
<button on:click=increment>
|
||||
{value}
|
||||
</button>
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `SimpleCounter` function itself runs once. The `value` signal is created once. The framework hands off the `increment` function to the browser as an event listener. When you click the button, the browser calls `increment`, which updates `value` via `set_value`. And that updates the single text node represented in our view by `{value}`.
|
||||
|
||||
Closures are key to reactivity. They provide the framework with the ability to rerun the smallest possible unit of your application in responsive to a change.
|
||||
Closures are key to reactivity. They provide the framework with the ability to re-run the smallest possible unit of your application in responsive to a change.
|
||||
|
||||
So remember two things:
|
||||
|
||||
|
||||
@@ -15,11 +15,11 @@ For example, instead of embedding logic in a component directly like this:
|
||||
```rust
|
||||
#[component]
|
||||
pub fn TodoApp(cx: Scope) -> impl IntoView {
|
||||
let (todos, set_todos) = create_signal(cx, vec![Todo { /* ... */ }]);
|
||||
// ⚠️ this is hard to test because it's embedded in the component
|
||||
let num_remaining = move || todos.with(|todos| {
|
||||
todos.iter().filter(|todo| !todo.completed).sum()
|
||||
});
|
||||
let (todos, set_todos) = create_signal(cx, vec![Todo { /* ... */ }]);
|
||||
// ⚠️ this is hard to test because it's embedded in the component
|
||||
let maximum = move || todos.with(|todos| {
|
||||
todos.iter().filter(|todo| todo.completed).sum()
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
@@ -29,24 +29,24 @@ You could pull that logic out into a separate data structure and test it:
|
||||
pub struct Todos(Vec<Todo>);
|
||||
|
||||
impl Todos {
|
||||
pub fn num_remaining(&self) -> usize {
|
||||
todos.iter().filter(|todo| !todo.completed).sum()
|
||||
}
|
||||
pub fn remaining(&self) -> usize {
|
||||
todos.iter().filter(|todo| todo.completed).sum()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn test_remaining {
|
||||
// ...
|
||||
}
|
||||
#[test]
|
||||
fn test_remaining {
|
||||
// ...
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn TodoApp(cx: Scope) -> impl IntoView {
|
||||
let (todos, set_todos) = create_signal(cx, Todos(vec![Todo { /* ... */ }]));
|
||||
// ✅ this has a test associated with it
|
||||
let num_remaining = move || todos.with(Todos::num_remaining);
|
||||
let (todos, set_todos) = create_signal(cx, Todos(vec![Todo { /* ... */ }]));
|
||||
// ✅ this has a test associated with it
|
||||
let maximum = move || todos.with(Todos::remaining);
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ fn App(cx: Scope) -> impl IntoView {
|
||||
set_count.update(|n| *n += 1);
|
||||
}
|
||||
>
|
||||
"Click me: "
|
||||
"Click me"
|
||||
{move || count.get()}
|
||||
</button>
|
||||
}
|
||||
@@ -61,7 +61,7 @@ Every component is a function with the following characteristics
|
||||
|
||||
## The Component Body
|
||||
The body of the component function is a set-up function that runs once, not a
|
||||
render function that reruns multiple times. You’ll typically use it to create a
|
||||
render function that re-runs multiple times. You’ll typically use it to create a
|
||||
few reactive variables, define any side effects that run in response to those values
|
||||
changing, and describe the user interface.
|
||||
|
||||
@@ -110,7 +110,7 @@ than they’ve ever used in their lives. And fair enough. Basically, passing a f
|
||||
into the view tells the framework: “Hey, this is something that might change.”
|
||||
|
||||
When we click the button and call `set_count`, the `count` signal is updated. This
|
||||
`move || count.get()` closure, whose value depends on the value of `count`, reruns,
|
||||
`move || count.get()` closure, whose value depends on the value of `count`, re-runs,
|
||||
and the framework makes a targeted update to that one specific text node, touching
|
||||
nothing else in your application. This is what allows for extremely efficient updates
|
||||
to the DOM.
|
||||
|
||||
@@ -18,7 +18,7 @@ let double_count = move || count() * 2;
|
||||
view! {
|
||||
<progress
|
||||
max="50"
|
||||
value=count
|
||||
value=progress
|
||||
/>
|
||||
<progress
|
||||
max="50"
|
||||
@@ -212,7 +212,7 @@ fn ProgressBar<F>(
|
||||
progress: F
|
||||
) -> impl IntoView
|
||||
where
|
||||
F: Fn() -> i32 + 'static,
|
||||
F: Fn() -> i32
|
||||
{
|
||||
view! { cx,
|
||||
<progress
|
||||
@@ -227,14 +227,14 @@ This is a perfectly reasonable way to write this component: `progress` now takes
|
||||
any value that implements this `Fn()` trait.
|
||||
|
||||
> Note that generic component props _cannot_ be specified inline (as `<F: Fn() -> i32>`)
|
||||
or as `progress: impl Fn() -> i32 + 'static,`, in part because they’re actually used to generate
|
||||
or as `progress: impl Fn() -> i32`, in part because they’re actually used to generate
|
||||
a `struct ProgressBarProps`, and struct fields cannot be `impl` types.
|
||||
|
||||
### `into` Props
|
||||
|
||||
There’s one more way we could implement this, and it would be to use `#[prop(into)]`.
|
||||
This attribute automatically calls `.into()` on the values you pass as props,
|
||||
which allows you to easily pass props with different values.
|
||||
This attribute automatically calls `.into()` on the values you pass as proprs,
|
||||
which allows you to pass props of different values easily.
|
||||
|
||||
In this case, it’s helpful to know about the
|
||||
[`Signal`](https://docs.rs/leptos/latest/leptos/struct.Signal.html) type. `Signal`
|
||||
|
||||
@@ -13,7 +13,7 @@ Leptos supports to two different patterns for iterating over items:
|
||||
|
||||
Sometimes you need to show an item repeatedly, but the list you’re drawing from
|
||||
does not often change. In this case, it’s important to know that you can insert
|
||||
any `Vec<IV> where IV: IntoView` into your view. In other words, if you can render
|
||||
any `Vec<IV> where IV: IntoView` into your view. In other views, if you can render
|
||||
`T`, you can render `Vec<T>`.
|
||||
|
||||
```rust
|
||||
@@ -85,4 +85,4 @@ it is generated, and using that as an ID for the key function.
|
||||
|
||||
Check out the `<DynamicList/>` component below for an example.
|
||||
|
||||
<iframe src="https://codesandbox.io/p/sandbox/4-iteration-sglt1o?file=%2Fsrc%2Fmain.rs&selection=%5B%7B%22endColumn%22%3A6%2C%22endLineNumber%22%3A55%2C%22startColumn%22%3A5%2C%22startLineNumber%22%3A31%7D%5D" width="100%" height="1000px"></iframe>
|
||||
<iframe src="https://codesandbox.io/p/sandbox/4-iteration-sglt1o?file=%2Fsrc%2Fmain.rs&selection=%5B%7B%22endColumn%22%3A6%2C%22endLineNumber%22%3A55%2C%22startColumn%22%3A5%2C%22startLineNumber%22%3A31%7D%5D" width="100%" height="100px"></iframe>
|
||||
|
||||
@@ -54,7 +54,7 @@ event.
|
||||
```rust
|
||||
let (name, set_name) = create_signal(cx, "Uncontrolled".to_string());
|
||||
|
||||
let input_element: NodeRef<Input> = create_node_ref(cx);
|
||||
let input_element: NodeRef<HtmlElement<Input>> = NodeRef::new(cx);
|
||||
```
|
||||
`NodeRef` is a kind of reactive smart pointer: we can use it to access the
|
||||
underlying DOM node. Its value will be set when the element is rendered.
|
||||
|
||||
@@ -26,7 +26,7 @@ things:
|
||||
all of which can be rendered. Spending time in the `Option` and `Result` docs in particular
|
||||
is one of the best ways to level up your Rust game.
|
||||
4. And always remember: to be reactive, values must be functions. You’ll see me constantly
|
||||
wrap things in a `move ||` closure, below. This is to ensure that they actually rerun
|
||||
wrap things in a `move ||` closure, below. This is to ensure that they actually re-run
|
||||
when the signal they depend on changes, keeping the UI reactive.
|
||||
|
||||
## So What?
|
||||
@@ -55,13 +55,13 @@ if it’s even. Well, how about this?
|
||||
|
||||
```rust
|
||||
view! { cx,
|
||||
<p>
|
||||
{move || if is_odd() {
|
||||
"Odd"
|
||||
} else {
|
||||
"Even"
|
||||
}}
|
||||
</p>
|
||||
<p>
|
||||
{move || if is_odd() {
|
||||
"Odd"
|
||||
} else {
|
||||
"Even"
|
||||
}}
|
||||
</p>
|
||||
}
|
||||
```
|
||||
|
||||
@@ -74,15 +74,15 @@ Let’s say we want to render some text if it’s odd, and nothing if it’s eve
|
||||
|
||||
```rust
|
||||
let message = move || {
|
||||
if is_odd() {
|
||||
Some("Ding ding ding!")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
if is_odd() {
|
||||
Some("Ding ding ding!")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
view! { cx,
|
||||
<p>{message}</p>
|
||||
<p>{message}</p>
|
||||
}
|
||||
```
|
||||
|
||||
@@ -91,7 +91,7 @@ This works fine. We can make it a little shorter if we’d like, using `bool::th
|
||||
```rust
|
||||
let message = move || is_odd().then(|| "Ding ding ding!");
|
||||
view! { cx,
|
||||
<p>{message}</p>
|
||||
<p>{message}</p>
|
||||
}
|
||||
```
|
||||
|
||||
@@ -105,15 +105,15 @@ pattern matching at your disposal.
|
||||
|
||||
```rust
|
||||
let message = move || {
|
||||
match value() {
|
||||
0 => "Zero",
|
||||
1 => "One",
|
||||
n if is_odd() => "Odd",
|
||||
_ => "Even"
|
||||
}
|
||||
match value() {
|
||||
0 => "Zero",
|
||||
1 => "One",
|
||||
n if is_odd() => "Odd",
|
||||
_ => "Even"
|
||||
}
|
||||
};
|
||||
view! { cx,
|
||||
<p>{message}</p>
|
||||
<p>{message}</p>
|
||||
}
|
||||
```
|
||||
|
||||
@@ -134,13 +134,13 @@ But consider the following example:
|
||||
let (value, set_value) = create_signal(cx, 0);
|
||||
|
||||
let message = move || if value() > 5 {
|
||||
"Big"
|
||||
"Big"
|
||||
} else {
|
||||
"Small"
|
||||
"Small"
|
||||
};
|
||||
|
||||
view! { cx,
|
||||
<p>{message}</p>
|
||||
<p>{message}</p>
|
||||
}
|
||||
```
|
||||
|
||||
@@ -148,11 +148,11 @@ This _works_, for sure. But if you added a log, you might be surprised
|
||||
|
||||
```rust
|
||||
let message = move || if value() > 5 {
|
||||
log!("{}: rendering Big", value());
|
||||
"Big"
|
||||
log!("{}: rendering Big", value());
|
||||
"Big"
|
||||
} else {
|
||||
log!("{}: rendering Small", value());
|
||||
"Small"
|
||||
log!("{}: rendering Small", value());
|
||||
"Small"
|
||||
};
|
||||
```
|
||||
|
||||
@@ -177,9 +177,9 @@ like this:
|
||||
|
||||
```rust
|
||||
let message = move || if value() > 5 {
|
||||
<Big/>
|
||||
<Big/>
|
||||
} else {
|
||||
<Small/>
|
||||
<Small/>
|
||||
};
|
||||
```
|
||||
|
||||
@@ -228,20 +228,20 @@ different branches of a conditional:
|
||||
|
||||
```rust,compile_error
|
||||
view! { cx,
|
||||
<main>
|
||||
{move || match is_odd() {
|
||||
true if value() == 1 => {
|
||||
// returns HtmlElement<Pre>
|
||||
view! { cx, <pre>"One"</pre> }
|
||||
},
|
||||
false if value() == 2 => {
|
||||
// returns HtmlElement<P>
|
||||
view! { cx, <p>"Two"</p> }
|
||||
}
|
||||
// returns HtmlElement<Textarea>
|
||||
_ => view! { cx, <textarea>{value()}</textarea> }
|
||||
}}
|
||||
</main>
|
||||
<main>
|
||||
{move || match is_odd() {
|
||||
true if value() == 1 => {
|
||||
// returns HtmlElement<Pre>
|
||||
view! { cx, <pre>"One"</pre> }
|
||||
},
|
||||
false if value() == 2 => {
|
||||
// returns HtmlElement<P>
|
||||
view! { cx, <p>"Two"</p> }
|
||||
}
|
||||
// returns HtmlElement<Textarea>
|
||||
_ => view! { cx, <textarea>{value()}</textarea> }
|
||||
}}
|
||||
</main>
|
||||
}
|
||||
```
|
||||
|
||||
@@ -265,20 +265,20 @@ Here’s the same example, with the conversion added:
|
||||
|
||||
```rust,compile_error
|
||||
view! { cx,
|
||||
<main>
|
||||
{move || match is_odd() {
|
||||
true if value() == 1 => {
|
||||
// returns HtmlElement<Pre>
|
||||
view! { cx, <pre>"One"</pre> }.into_any()
|
||||
},
|
||||
false if value() == 2 => {
|
||||
// returns HtmlElement<P>
|
||||
view! { cx, <p>"Two"</p> }.into_any()
|
||||
}
|
||||
// returns HtmlElement<Textarea>
|
||||
_ => view! { cx, <textarea>{value()}</textarea> }.into_any()
|
||||
}}
|
||||
</main>
|
||||
<main>
|
||||
{move || match is_odd() {
|
||||
true if value() == 1 => {
|
||||
// returns HtmlElement<Pre>
|
||||
view! { cx, <pre>"One"</pre> }.into_any()
|
||||
},
|
||||
false if value() == 2 => {
|
||||
// returns HtmlElement<P>
|
||||
view! { cx, <p>"Two"</p> }.into_any()
|
||||
}
|
||||
// returns HtmlElement<Textarea>
|
||||
_ => view! { cx, <textarea>{value()}</textarea> }.into_any()
|
||||
}}
|
||||
</main>
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ Let’s add an `<ErrorBoundary/>` to this example.
|
||||
fn NumericInput(cx: Scope) -> impl IntoView {
|
||||
let (value, set_value) = create_signal(cx, Ok(0));
|
||||
|
||||
let on_input = move |ev| set_value(event_target_value(&ev).parse::<i32>());
|
||||
let on_input = move |ev| set_value(event_target_value(&ev).parse::<i32>());
|
||||
|
||||
view! { cx,
|
||||
<h1>"Error Handling"</h1>
|
||||
@@ -77,7 +77,9 @@ fn NumericInput(cx: Scope) -> impl IntoView {
|
||||
<p>"Not a number! Errors: "</p>
|
||||
// we can render a list of errors as strings, if we'd like
|
||||
<ul>
|
||||
{move || errors.get()
|
||||
{move || errors.unwrap()
|
||||
.get()
|
||||
.0
|
||||
.into_iter()
|
||||
.map(|(_, e)| view! { cx, <li>{e.to_string()}</li>})
|
||||
.collect::<Vec<_>>()
|
||||
|
||||
@@ -30,11 +30,11 @@ it in the child. This lets you manipulate the state of the parent from the child
|
||||
```rust
|
||||
#[component]
|
||||
pub fn App(cx: Scope) -> impl IntoView {
|
||||
let (toggled, set_toggled) = create_signal(cx, false);
|
||||
view! { cx,
|
||||
<p>"Toggled? " {toggled}</p>
|
||||
<ButtonA setter=set_toggled/>
|
||||
}
|
||||
let (toggled, set_toggled) = create_signal(cx, false);
|
||||
view! { cx,
|
||||
<p>"Toggled? " {toggled}</p>
|
||||
<ButtonA setter=set_toggled/>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
@@ -63,11 +63,11 @@ Another approach would be to pass a callback to the child: say, `on_click`.
|
||||
```rust
|
||||
#[component]
|
||||
pub fn App(cx: Scope) -> impl IntoView {
|
||||
let (toggled, set_toggled) = create_signal(cx, false);
|
||||
view! { cx,
|
||||
<p>"Toggled? " {toggled}</p>
|
||||
<ButtonB on_click=move |_| set_toggled.update(|value| *value = !*value)/>
|
||||
}
|
||||
let (toggled, set_toggled) = create_signal(cx, false);
|
||||
view! { cx,
|
||||
<p>"Toggled? " {toggled}</p>
|
||||
<ButtonB on_click=move |_| set_toggled.update(|value| *value = !*value)/>
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -106,13 +106,13 @@ in your `view` macro in `<App/>`.
|
||||
```rust
|
||||
#[component]
|
||||
pub fn App(cx: Scope) -> impl IntoView {
|
||||
let (toggled, set_toggled) = create_signal(cx, false);
|
||||
view! { cx,
|
||||
<p>"Toggled? " {toggled}</p>
|
||||
// note the on:click instead of on_click
|
||||
// this is the same syntax as an HTML element event listener
|
||||
<ButtonC on:click=move |_| set_toggled.update(|value| *value = !*value)/>
|
||||
}
|
||||
let (toggled, set_toggled) = create_signal(cx, false);
|
||||
view! { cx,
|
||||
<p>"Toggled? " {toggled}</p>
|
||||
// note the on:click instead of on_click
|
||||
// this is the same syntax as an HTML element event listener
|
||||
<ButtonC on:click=move |_| set_toggled.update(|value| *value = !*value)/>
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -142,32 +142,31 @@ tree:
|
||||
```rust
|
||||
#[component]
|
||||
pub fn App(cx: Scope) -> impl IntoView {
|
||||
let (toggled, set_toggled) = create_signal(cx, false);
|
||||
view! { cx,
|
||||
<p>"Toggled? " {toggled}</p>
|
||||
<Layout/>
|
||||
}
|
||||
let (toggled, set_toggled) = create_signal(cx, false);
|
||||
view! { cx,
|
||||
<p>"Toggled? " {toggled}</p>
|
||||
<Layout/>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn Layout(cx: Scope) -> impl IntoView {
|
||||
view! { cx,
|
||||
<header>
|
||||
<h1>"My Page"</h1>
|
||||
</header>
|
||||
<main>
|
||||
<Content/>
|
||||
</main>
|
||||
}
|
||||
view! { cx,
|
||||
<header>
|
||||
<h1>"My Page"</h1>
|
||||
<main>
|
||||
<Content/>
|
||||
</main>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn Content(cx: Scope) -> impl IntoView {
|
||||
view! { cx,
|
||||
<div class="content">
|
||||
<ButtonD/>
|
||||
</div>
|
||||
}
|
||||
view! { cx,
|
||||
<div class="content">
|
||||
<ButtonD/>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
@@ -183,32 +182,31 @@ pass your `WriteSignal` to its props. You could do what’s sometimes called
|
||||
```rust
|
||||
#[component]
|
||||
pub fn App(cx: Scope) -> impl IntoView {
|
||||
let (toggled, set_toggled) = create_signal(cx, false);
|
||||
view! { cx,
|
||||
<p>"Toggled? " {toggled}</p>
|
||||
<Layout set_toggled/>
|
||||
}
|
||||
let (toggled, set_toggled) = create_signal(cx, false);
|
||||
view! { cx,
|
||||
<p>"Toggled? " {toggled}</p>
|
||||
<Layout set_toggled/>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn Layout(cx: Scope, set_toggled: WriteSignal<bool>) -> impl IntoView {
|
||||
view! { cx,
|
||||
<header>
|
||||
<h1>"My Page"</h1>
|
||||
</header>
|
||||
<main>
|
||||
<Content set_toggled/>
|
||||
</main>
|
||||
}
|
||||
view! { cx,
|
||||
<header>
|
||||
<h1>"My Page"</h1>
|
||||
<main>
|
||||
<Content set_toggled/>
|
||||
</main>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn Content(cx: Scope, set_toggled: WriteSignal<bool>) -> impl IntoView {
|
||||
view! { cx,
|
||||
<div class="content">
|
||||
<ButtonD set_toggled/>
|
||||
</div>
|
||||
}
|
||||
view! { cx,
|
||||
<div class="content">
|
||||
<ButtonD set_toggled/>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
@@ -238,26 +236,26 @@ unnecessary prop drilling.
|
||||
```rust
|
||||
#[component]
|
||||
pub fn App(cx: Scope) -> impl IntoView {
|
||||
let (toggled, set_toggled) = create_signal(cx, false);
|
||||
let (toggled, set_toggled) = create_signal(cx, false);
|
||||
|
||||
// share `set_toggled` with all children of this component
|
||||
provide_context(cx, set_toggled);
|
||||
// share `set_toggled` with all children of this component
|
||||
provide_context(cx, set_toggled);
|
||||
|
||||
view! { cx,
|
||||
<p>"Toggled? " {toggled}</p>
|
||||
<Layout/>
|
||||
}
|
||||
view! { cx,
|
||||
<p>"Toggled? " {toggled}</p>
|
||||
<Layout/>
|
||||
}
|
||||
}
|
||||
|
||||
// <Layout/> and <Content/> omitted
|
||||
|
||||
#[component]
|
||||
pub fn ButtonD(cx: Scope) -> impl IntoView {
|
||||
// use_context searches up the context tree, hoping to
|
||||
// find a `WriteSignal<bool>`
|
||||
// in this case, I .expect() because I know I provided it
|
||||
let setter = use_context::<WriteSignal<bool>>(cx)
|
||||
.expect("to have found the setter provided");
|
||||
// use_context searches up the context tree, hoping to
|
||||
// find a `WriteSignal<bool>`
|
||||
// in this case, I .expect() because I know I provided it
|
||||
let setter = use_context::<WriteSignal<bool>>(cx)
|
||||
.expect("to have found the setter provided");
|
||||
|
||||
view! { cx,
|
||||
<button
|
||||
|
||||
@@ -6,15 +6,15 @@ that enhances an HTML `<form>`. I need some way to pass all its inputs.
|
||||
|
||||
```rust
|
||||
view! { cx,
|
||||
<Form>
|
||||
<fieldset>
|
||||
<label>
|
||||
"Some Input"
|
||||
<input type="text" name="something"/>
|
||||
</label>
|
||||
</fieldset>
|
||||
<button>"Submit"</button>
|
||||
</Form>
|
||||
<Form>
|
||||
<fieldset>
|
||||
<label>
|
||||
"Some Input"
|
||||
<input type="text" name="something"/>
|
||||
</label>
|
||||
</fieldset>
|
||||
<button>"Submit"</button>
|
||||
</Form>
|
||||
}
|
||||
```
|
||||
|
||||
@@ -30,13 +30,13 @@ In fact, you’ve already seen these both in action in the [`<Show/>`](/view/06_
|
||||
```rust
|
||||
view! { cx,
|
||||
<Show
|
||||
// `when` is a normal prop
|
||||
// `when` is a normal prop
|
||||
when=move || value() > 5
|
||||
// `fallback` is a "render prop": a function that returns a view
|
||||
// `fallback` is a "render prop": a function that returns a view
|
||||
fallback=|cx| view! { cx, <Small/> }
|
||||
>
|
||||
// `<Big/>` (and anything else here)
|
||||
// will be given to the `children` prop
|
||||
// `<Big/>` (and anything else here)
|
||||
// will be given to the `children` prop
|
||||
<Big/>
|
||||
</Show>
|
||||
}
|
||||
@@ -62,7 +62,7 @@ where
|
||||
<h2>"Render Prop"</h2>
|
||||
{render_prop()}
|
||||
|
||||
<h2>"Children"</h2>
|
||||
<h2>"Children"</h2>
|
||||
{children(cx)}
|
||||
}
|
||||
}
|
||||
@@ -79,11 +79,11 @@ We can use the component like this:
|
||||
|
||||
```rust
|
||||
view! { cx,
|
||||
<TakesChildren render_prop=|| view! { cx, <p>"Hi, there!"</p> }>
|
||||
// these get passed to `children`
|
||||
"Some text"
|
||||
<span>"A span"</span>
|
||||
</TakesChildren>
|
||||
<TakesChildren render_prop=|| view! { cx, <p>"Hi, there!"</p> }>
|
||||
// these get passed to `children`
|
||||
"Some text"
|
||||
<span>"A span"</span>
|
||||
</TakesChildren>
|
||||
}
|
||||
```
|
||||
|
||||
@@ -115,11 +115,11 @@ Calling it like this will create a list:
|
||||
|
||||
```rust
|
||||
view! { cx,
|
||||
<WrappedChildren>
|
||||
"A"
|
||||
"B"
|
||||
"C"
|
||||
</WrappedChildren>
|
||||
<WrappedChildren>
|
||||
"A"
|
||||
"B"
|
||||
"C"
|
||||
</WrappedChildren>
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -10,7 +10,5 @@ log = "0.4"
|
||||
console_error_panic_hook = "0.1.7"
|
||||
|
||||
[dev-dependencies]
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-test = "0.3.0"
|
||||
web-sys ="0.3"
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
use counter::*;
|
||||
use leptos::*;
|
||||
use wasm_bindgen::JsCast;
|
||||
use wasm_bindgen_test::*;
|
||||
|
||||
wasm_bindgen_test_configure!(run_in_browser);
|
||||
use counter::*;
|
||||
use leptos::*;
|
||||
use web_sys::HtmlElement;
|
||||
|
||||
#[wasm_bindgen_test]
|
||||
fn clear() {
|
||||
@@ -84,22 +84,22 @@ fn inc() {
|
||||
let clear = div
|
||||
.first_child()
|
||||
.unwrap()
|
||||
.dyn_into::<web_sys::HtmlElement>()
|
||||
.dyn_into::<HtmlElement>()
|
||||
.unwrap();
|
||||
let dec = clear
|
||||
.next_sibling()
|
||||
.unwrap()
|
||||
.dyn_into::<web_sys::HtmlElement>()
|
||||
.dyn_into::<HtmlElement>()
|
||||
.unwrap();
|
||||
let text = dec
|
||||
.next_sibling()
|
||||
.unwrap()
|
||||
.dyn_into::<web_sys::HtmlElement>()
|
||||
.dyn_into::<HtmlElement>()
|
||||
.unwrap();
|
||||
let inc = text
|
||||
.next_sibling()
|
||||
.unwrap()
|
||||
.dyn_into::<web_sys::HtmlElement>()
|
||||
.dyn_into::<HtmlElement>()
|
||||
.unwrap();
|
||||
|
||||
inc.click();
|
||||
|
||||
@@ -24,6 +24,7 @@ pub fn App(cx: Scope) -> impl IntoView {
|
||||
// as strings, if we'd like
|
||||
<ul>
|
||||
{move || errors.get()
|
||||
.0
|
||||
.into_iter()
|
||||
.map(|(_, e)| view! { cx, <li>{e.to_string()}</li>})
|
||||
.collect::<Vec<_>>()
|
||||
|
||||
@@ -13,7 +13,7 @@ console_error_panic_hook = "0.1.7"
|
||||
futures = "0.3.25"
|
||||
cfg-if = "1.0.0"
|
||||
leptos = { path = "../../../leptos/leptos", default-features = false, features = [
|
||||
"serde",
|
||||
"serde",
|
||||
] }
|
||||
leptos_axum = { path = "../../../leptos/integrations/axum", default-features = false, optional = true }
|
||||
leptos_meta = { path = "../../../leptos/meta", default-features = false }
|
||||
@@ -27,7 +27,7 @@ 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 }
|
||||
tower-http = { version = "0.3.4", features = ["fs"], optional = true }
|
||||
tokio = { version = "1.22.0", features = ["full"], optional = true }
|
||||
http = { version = "0.2.8" }
|
||||
thiserror = "1.0.38"
|
||||
@@ -39,14 +39,14 @@ default = ["csr"]
|
||||
csr = ["leptos/csr", "leptos_meta/csr", "leptos_router/csr"]
|
||||
hydrate = ["leptos/hydrate", "leptos_meta/hydrate", "leptos_router/hydrate"]
|
||||
ssr = [
|
||||
"dep:axum",
|
||||
"dep:tower",
|
||||
"dep:tower-http",
|
||||
"dep:tokio",
|
||||
"leptos/ssr",
|
||||
"leptos_meta/ssr",
|
||||
"leptos_router/ssr",
|
||||
"dep:leptos_axum",
|
||||
"dep:axum",
|
||||
"dep:tower",
|
||||
"dep:tower-http",
|
||||
"dep:tokio",
|
||||
"leptos/ssr",
|
||||
"leptos_meta/ssr",
|
||||
"leptos_router/ssr",
|
||||
"dep:leptos_axum",
|
||||
]
|
||||
|
||||
[package.metadata.cargo-all-features]
|
||||
@@ -54,12 +54,12 @@ 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
|
||||
# The name used by wasm-bindgen/cargo-leptos for the JS/WASM bundle. Defaults to the crate name
|
||||
output-name = "errors_axum"
|
||||
# The site root folder is where cargo-leptos generate all output. WARNING: all content of this folder will be erased on a rebuild. Use it in your server setup.
|
||||
site-root = "target/site"
|
||||
# The site-root relative folder where all compiled output (JS, WASM and CSS) is written
|
||||
# Defaults to pkg
|
||||
# Defaults to pkg
|
||||
site-pkg-dir = "pkg"
|
||||
# [Optional] The source CSS file. If it ends with .sass or .scss then it will be compiled by dart-sass into CSS. The CSS is optimized by Lightning CSS before being written to <site-root>/<site-pkg>/app.css
|
||||
style-file = "./style.css"
|
||||
|
||||
@@ -34,7 +34,7 @@ where
|
||||
abort_controller.abort()
|
||||
}
|
||||
});
|
||||
T::de(&json).ok()
|
||||
T::from_json(&json).ok()
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
@@ -49,7 +49,7 @@ where
|
||||
.text()
|
||||
.await
|
||||
.ok()?;
|
||||
T::de(&json).map_err(|e| log::error!("{e}")).ok()
|
||||
T::from_json(&json).map_err(|e| log::error!("{e}")).ok()
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, PartialEq, Eq, Clone)]
|
||||
|
||||
@@ -13,7 +13,7 @@ console_error_panic_hook = "0.1.7"
|
||||
futures = "0.3.25"
|
||||
cfg-if = "1.0.0"
|
||||
leptos = { path = "../../leptos", default-features = false, features = [
|
||||
"serde",
|
||||
"serde",
|
||||
] }
|
||||
leptos_axum = { path = "../../integrations/axum", optional = true }
|
||||
leptos_meta = { path = "../../meta", default-features = false }
|
||||
@@ -26,7 +26,7 @@ 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 }
|
||||
tower-http = { version = "0.3.4", features = ["fs"], optional = true }
|
||||
tokio = { version = "1.22.0", features = ["full"], optional = true }
|
||||
http = { version = "0.2.8", optional = true }
|
||||
web-sys = { version = "0.3", features = ["AbortController", "AbortSignal"] }
|
||||
@@ -38,15 +38,15 @@ default = ["csr"]
|
||||
csr = ["leptos/csr", "leptos_meta/csr", "leptos_router/csr"]
|
||||
hydrate = ["leptos/hydrate", "leptos_meta/hydrate", "leptos_router/hydrate"]
|
||||
ssr = [
|
||||
"dep:axum",
|
||||
"dep:tower",
|
||||
"dep:tower-http",
|
||||
"dep:tokio",
|
||||
"dep:http",
|
||||
"leptos/ssr",
|
||||
"leptos_axum",
|
||||
"leptos_meta/ssr",
|
||||
"leptos_router/ssr",
|
||||
"dep:axum",
|
||||
"dep:tower",
|
||||
"dep:tower-http",
|
||||
"dep:tokio",
|
||||
"dep:http",
|
||||
"leptos/ssr",
|
||||
"leptos_axum",
|
||||
"leptos_meta/ssr",
|
||||
"leptos_router/ssr",
|
||||
]
|
||||
|
||||
[package.metadata.cargo-all-features]
|
||||
@@ -54,27 +54,27 @@ denylist = ["axum", "tower", "tower-http", "tokio", "http", "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 = "hackernews_axum"
|
||||
# The name used by wasm-bindgen/cargo-leptos for the JS/WASM bundle. Defaults to the crate name
|
||||
output-name = "hackernews_axum"
|
||||
# The site root folder is where cargo-leptos generate all output. WARNING: all content of this folder will be erased on a rebuild. Use it in your server setup.
|
||||
site-root = "target/site"
|
||||
# The site-root relative folder where all compiled output (JS, WASM and CSS) is written
|
||||
# Defaults to pkg
|
||||
site-pkg-dir = "pkg"
|
||||
# Defaults to pkg
|
||||
site-pkg-dir = "pkg"
|
||||
# [Optional] The source CSS file. If it ends with .sass or .scss then it will be compiled by dart-sass into CSS. The CSS is optimized by Lightning CSS before being written to <site-root>/<site-pkg>/app.css
|
||||
style-file = "./style.css"
|
||||
# [Optional] Files in the asset-dir will be copied to the site-root directory
|
||||
assets-dir = "public"
|
||||
# The IP and port (ex: 127.0.0.1:3000) where the server serves the content. Use it in your server setup.
|
||||
site-addr = "127.0.0.1:3000"
|
||||
site-addr = "127.0.0.1:3000"
|
||||
# The port to use for automatic reload monitoring
|
||||
reload-port = 3001
|
||||
reload-port = 3001
|
||||
# [Optional] Command to use when running end2end tests. It will run in the end2end dir.
|
||||
end2end-cmd = "npx playwright test"
|
||||
# The browserlist query used for optimizing the CSS.
|
||||
browserquery = "defaults"
|
||||
browserquery = "defaults"
|
||||
# Set by cargo-leptos watch when building with tha tool. Controls whether autoreload JS will be included in the head
|
||||
watch = false
|
||||
watch = false
|
||||
# The environment Leptos will run in, usually either "DEV" or "PROD"
|
||||
env = "DEV"
|
||||
# The features to use when compiling the bin target
|
||||
@@ -95,4 +95,4 @@ 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
|
||||
lib-default-features = false
|
||||
@@ -34,7 +34,7 @@ where
|
||||
abort_controller.abort()
|
||||
}
|
||||
});
|
||||
T::de(&json).ok()
|
||||
T::from_json(&json).ok()
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
@@ -49,7 +49,7 @@ where
|
||||
.text()
|
||||
.await
|
||||
.ok()?;
|
||||
T::de(&json).map_err(|e| log::error!("{e}")).ok()
|
||||
T::from_json(&json).map_err(|e| log::error!("{e}")).ok()
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, PartialEq, Eq, Clone)]
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
[workspace]
|
||||
members = ["client", "api-boundary", "server"]
|
||||
|
||||
[patch.crates-io]
|
||||
leptos = { path = "../../leptos" }
|
||||
leptos_router = { path = "../../router" }
|
||||
api-boundary = { path = "api-boundary" }
|
||||
@@ -1,9 +0,0 @@
|
||||
[tasks.build]
|
||||
command = "cargo"
|
||||
args = ["+nightly", "build-all-features"]
|
||||
install_crate = "cargo-all-features"
|
||||
|
||||
[tasks.check]
|
||||
command = "cargo"
|
||||
args = ["+nightly", "check-all-features"]
|
||||
install_crate = "cargo-all-features"
|
||||
@@ -1,23 +0,0 @@
|
||||
# Leptos Login Example
|
||||
|
||||
This example demonstrates a scenario of a client-side rendered application
|
||||
that uses an existing API that you cannot or do not want to change.
|
||||
The authentications of this example are done using an API token.
|
||||
|
||||
## Run
|
||||
|
||||
First start the example server:
|
||||
|
||||
```
|
||||
cd server/ && cargo run
|
||||
```
|
||||
|
||||
then use [`trunk`](https://trunkrs.dev) to serve the SPA:
|
||||
|
||||
```
|
||||
cd client/ && trunk serve
|
||||
```
|
||||
|
||||
finally you can visit the web application at `http://localhost:8080`
|
||||
|
||||
The `api-boundary` crate contains data structures that are used by the server and the client.
|
||||
@@ -1,8 +0,0 @@
|
||||
[package]
|
||||
name = "api-boundary"
|
||||
version = "0.0.0"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
@@ -1,22 +0,0 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct Credentials {
|
||||
pub email: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct UserInfo {
|
||||
pub email: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct ApiToken {
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Error {
|
||||
pub message: String,
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
[package]
|
||||
name = "client"
|
||||
version = "0.0.0"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
api-boundary = "*"
|
||||
|
||||
leptos = { version = "0.2.0-alpha2", features = ["stable"] }
|
||||
leptos_router = { version = "0.2.0-alpha2", features = ["stable", "csr"] }
|
||||
|
||||
log = "0.4"
|
||||
console_error_panic_hook = "0.1"
|
||||
console_log = "0.2"
|
||||
gloo-net = "0.2"
|
||||
gloo-storage = "0.2"
|
||||
serde = "1.0"
|
||||
thiserror = "1.0"
|
||||
@@ -1,3 +0,0 @@
|
||||
[[proxy]]
|
||||
rewrite = "/api/"
|
||||
backend = "http://0.0.0.0:3000/"
|
||||
@@ -1,7 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<link data-trunk rel="rust" data-wasm-opt="z" data-weak-refs/>
|
||||
</head>
|
||||
<body></body>
|
||||
</html>
|
||||
@@ -1,94 +0,0 @@
|
||||
use gloo_net::http::{Request, Response};
|
||||
use serde::de::DeserializeOwned;
|
||||
use thiserror::Error;
|
||||
|
||||
use api_boundary::*;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct UnauthorizedApi {
|
||||
url: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AuthorizedApi {
|
||||
url: &'static str,
|
||||
token: ApiToken,
|
||||
}
|
||||
|
||||
impl UnauthorizedApi {
|
||||
pub const fn new(url: &'static str) -> Self {
|
||||
Self { url }
|
||||
}
|
||||
pub async fn register(&self, credentials: &Credentials) -> Result<()> {
|
||||
let url = format!("{}/users", self.url);
|
||||
let response = Request::post(&url).json(credentials)?.send().await?;
|
||||
into_json(response).await
|
||||
}
|
||||
pub async fn login(
|
||||
&self,
|
||||
credentials: &Credentials,
|
||||
) -> Result<AuthorizedApi> {
|
||||
let url = format!("{}/login", self.url);
|
||||
let response = Request::post(&url).json(credentials)?.send().await?;
|
||||
let token = into_json(response).await?;
|
||||
Ok(AuthorizedApi::new(self.url, token))
|
||||
}
|
||||
}
|
||||
|
||||
impl AuthorizedApi {
|
||||
pub const fn new(url: &'static str, token: ApiToken) -> Self {
|
||||
Self { url, token }
|
||||
}
|
||||
fn auth_header_value(&self) -> String {
|
||||
format!("Bearer {}", self.token.token)
|
||||
}
|
||||
async fn send<T>(&self, req: Request) -> Result<T>
|
||||
where
|
||||
T: DeserializeOwned,
|
||||
{
|
||||
let response = req
|
||||
.header("Authorization", &self.auth_header_value())
|
||||
.send()
|
||||
.await?;
|
||||
into_json(response).await
|
||||
}
|
||||
pub async fn logout(&self) -> Result<()> {
|
||||
let url = format!("{}/logout", self.url);
|
||||
self.send(Request::post(&url)).await
|
||||
}
|
||||
pub async fn user_info(&self) -> Result<UserInfo> {
|
||||
let url = format!("{}/users", self.url);
|
||||
self.send(Request::get(&url)).await
|
||||
}
|
||||
pub fn token(&self) -> &ApiToken {
|
||||
&self.token
|
||||
}
|
||||
}
|
||||
|
||||
type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum Error {
|
||||
#[error(transparent)]
|
||||
Fetch(#[from] gloo_net::Error),
|
||||
#[error("{0:?}")]
|
||||
Api(api_boundary::Error),
|
||||
}
|
||||
|
||||
impl From<api_boundary::Error> for Error {
|
||||
fn from(e: api_boundary::Error) -> Self {
|
||||
Self::Api(e)
|
||||
}
|
||||
}
|
||||
|
||||
async fn into_json<T>(response: Response) -> Result<T>
|
||||
where
|
||||
T: DeserializeOwned,
|
||||
{
|
||||
// ensure we've got 2xx status
|
||||
if response.ok() {
|
||||
Ok(response.json().await?)
|
||||
} else {
|
||||
Err(response.json::<api_boundary::Error>().await?.into())
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
use leptos::{ev, *};
|
||||
|
||||
#[component]
|
||||
pub fn CredentialsForm(
|
||||
cx: Scope,
|
||||
title: &'static str,
|
||||
action_label: &'static str,
|
||||
action: Action<(String, String), ()>,
|
||||
error: Signal<Option<String>>,
|
||||
disabled: Signal<bool>,
|
||||
) -> impl IntoView {
|
||||
let (password, set_password) = create_signal(cx, String::new());
|
||||
let (email, set_email) = create_signal(cx, String::new());
|
||||
|
||||
let dispatch_action =
|
||||
move || action.dispatch((email.get(), password.get()));
|
||||
|
||||
let button_is_disabled = Signal::derive(cx, move || {
|
||||
disabled.get() || password.get().is_empty() || email.get().is_empty()
|
||||
});
|
||||
|
||||
view! { cx,
|
||||
<form on:submit=|ev|ev.prevent_default()>
|
||||
<p>{ title }</p>
|
||||
{move || error.get().map(|err| view!{ cx,
|
||||
<p style ="color:red;" >{ err }</p>
|
||||
})}
|
||||
<input
|
||||
type = "email"
|
||||
required
|
||||
placeholder = "Email address"
|
||||
prop:disabled = move || disabled.get()
|
||||
on:keyup = move |ev: ev::KeyboardEvent| {
|
||||
let val = event_target_value(&ev);
|
||||
set_email.update(|v|*v = val);
|
||||
}
|
||||
// The `change` event fires when the browser fills the form automatically,
|
||||
on:change = move |ev| {
|
||||
let val = event_target_value(&ev);
|
||||
set_email.update(|v|*v = val);
|
||||
}
|
||||
/>
|
||||
<input
|
||||
type = "password"
|
||||
required
|
||||
placeholder = "Password"
|
||||
prop:disabled = move || disabled.get()
|
||||
on:keyup = move |ev: ev::KeyboardEvent| {
|
||||
match &*ev.key() {
|
||||
"Enter" => {
|
||||
dispatch_action();
|
||||
}
|
||||
_=> {
|
||||
let val = event_target_value(&ev);
|
||||
set_password.update(|p|*p = val);
|
||||
}
|
||||
}
|
||||
}
|
||||
// The `change` event fires when the browser fills the form automatically,
|
||||
on:change = move |ev| {
|
||||
let val = event_target_value(&ev);
|
||||
set_password.update(|p|*p = val);
|
||||
}
|
||||
/>
|
||||
<button
|
||||
prop:disabled = move || button_is_disabled.get()
|
||||
on:click = move |_| dispatch_action()
|
||||
>
|
||||
{ action_label }
|
||||
</button>
|
||||
</form>
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
pub mod credentials;
|
||||
pub mod navbar;
|
||||
|
||||
pub use self::{credentials::*, navbar::*};
|
||||
@@ -1,32 +0,0 @@
|
||||
use leptos::*;
|
||||
use leptos_router::*;
|
||||
|
||||
use crate::Page;
|
||||
|
||||
#[component]
|
||||
pub fn NavBar<F>(
|
||||
cx: Scope,
|
||||
logged_in: Signal<bool>,
|
||||
on_logout: F,
|
||||
) -> impl IntoView
|
||||
where
|
||||
F: Fn() + 'static + Clone,
|
||||
{
|
||||
view! { cx,
|
||||
<nav>
|
||||
<Show
|
||||
when = move || logged_in.get()
|
||||
fallback = |cx| view! { cx,
|
||||
<A href=Page::Login.path() >"Login"</A>
|
||||
" | "
|
||||
<A href=Page::Register.path() >"Register"</A>
|
||||
}
|
||||
>
|
||||
<a href="#" on:click={
|
||||
let on_logout = on_logout.clone();
|
||||
move |_| on_logout()
|
||||
}>"Logout"</a>
|
||||
</Show>
|
||||
</nav>
|
||||
}
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
use gloo_storage::{LocalStorage, Storage};
|
||||
use leptos::*;
|
||||
use leptos_router::*;
|
||||
|
||||
use api_boundary::*;
|
||||
|
||||
mod api;
|
||||
mod components;
|
||||
mod pages;
|
||||
|
||||
use self::{components::*, pages::*};
|
||||
|
||||
const DEFAULT_API_URL: &str = "/api";
|
||||
const API_TOKEN_STORAGE_KEY: &str = "api-token";
|
||||
|
||||
#[component]
|
||||
pub fn App(cx: Scope) -> impl IntoView {
|
||||
// -- signals -- //
|
||||
|
||||
let authorized_api = create_rw_signal(cx, None::<api::AuthorizedApi>);
|
||||
let user_info = create_rw_signal(cx, None::<UserInfo>);
|
||||
let logged_in = Signal::derive(cx, move || authorized_api.get().is_some());
|
||||
|
||||
// -- actions -- //
|
||||
|
||||
let fetch_user_info = create_action(cx, move |_| async move {
|
||||
match authorized_api.get() {
|
||||
Some(api) => match api.user_info().await {
|
||||
Ok(info) => {
|
||||
user_info.update(|i| *i = Some(info));
|
||||
}
|
||||
Err(err) => {
|
||||
log::error!("Unable to fetch user info: {err}")
|
||||
}
|
||||
},
|
||||
None => {
|
||||
log::error!("Unable to fetch user info: not logged in")
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let logout = create_action(cx, move |_| async move {
|
||||
match authorized_api.get() {
|
||||
Some(api) => match api.logout().await {
|
||||
Ok(_) => {
|
||||
authorized_api.update(|a| *a = None);
|
||||
user_info.update(|i| *i = None);
|
||||
}
|
||||
Err(err) => {
|
||||
log::error!("Unable to logout: {err}")
|
||||
}
|
||||
},
|
||||
None => {
|
||||
log::error!("Unable to logout user: not logged in")
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// -- callbacks -- //
|
||||
|
||||
let on_logout = move || {
|
||||
logout.dispatch(());
|
||||
};
|
||||
|
||||
// -- init API -- //
|
||||
|
||||
let unauthorized_api = api::UnauthorizedApi::new(DEFAULT_API_URL);
|
||||
if let Ok(token) = LocalStorage::get(API_TOKEN_STORAGE_KEY) {
|
||||
let api = api::AuthorizedApi::new(DEFAULT_API_URL, token);
|
||||
authorized_api.update(|a| *a = Some(api));
|
||||
fetch_user_info.dispatch(());
|
||||
}
|
||||
|
||||
log::debug!("User is logged in: {}", logged_in.get());
|
||||
|
||||
// -- effects -- //
|
||||
|
||||
create_effect(cx, move |_| {
|
||||
log::debug!("API authorization state changed");
|
||||
match authorized_api.get() {
|
||||
Some(api) => {
|
||||
log::debug!(
|
||||
"API is now authorized: save token in LocalStorage"
|
||||
);
|
||||
LocalStorage::set(API_TOKEN_STORAGE_KEY, api.token())
|
||||
.expect("LocalStorage::set");
|
||||
}
|
||||
None => {
|
||||
log::debug!("API is no longer authorized: delete token from LocalStorage");
|
||||
LocalStorage::delete(API_TOKEN_STORAGE_KEY);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
view! { cx,
|
||||
<Router>
|
||||
<NavBar logged_in on_logout />
|
||||
<main>
|
||||
<Routes>
|
||||
<Route
|
||||
path=Page::Home.path()
|
||||
view=move |cx| view! { cx,
|
||||
<Home user_info = user_info.into() />
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path=Page::Login.path()
|
||||
view=move |cx| view! { cx,
|
||||
<Login
|
||||
api = unauthorized_api
|
||||
on_success = move |api| {
|
||||
log::info!("Successfully logged in");
|
||||
authorized_api.update(|v| *v = Some(api));
|
||||
let navigate = use_navigate(cx);
|
||||
navigate(Page::Home.path(), Default::default()).expect("Home route");
|
||||
fetch_user_info.dispatch(());
|
||||
} />
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path=Page::Register.path()
|
||||
view=move |cx| view! { cx,
|
||||
<Register api = unauthorized_api />
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</main>
|
||||
</Router>
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
use leptos::*;
|
||||
|
||||
use client::*;
|
||||
|
||||
pub fn main() {
|
||||
_ = console_log::init_with_level(log::Level::Debug);
|
||||
console_error_panic_hook::set_once();
|
||||
mount_to_body(|cx| view! { cx, <App /> })
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
use crate::Page;
|
||||
use api_boundary::UserInfo;
|
||||
use leptos::*;
|
||||
use leptos_router::*;
|
||||
|
||||
#[component]
|
||||
pub fn Home(cx: Scope, user_info: Signal<Option<UserInfo>>) -> impl IntoView {
|
||||
view! { cx,
|
||||
<h2>"Leptos Login example"</h2>
|
||||
{move || match user_info.get() {
|
||||
Some(info) => view!{ cx,
|
||||
<p>"You are logged in with "{ info.email }"."</p>
|
||||
}.into_view(cx),
|
||||
None => view!{ cx,
|
||||
<p>"You are not logged in."</p>
|
||||
<A href=Page::Login.path() >"Login now."</A>
|
||||
}.into_view(cx)
|
||||
}}
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
use leptos::*;
|
||||
use leptos_router::*;
|
||||
|
||||
use api_boundary::*;
|
||||
|
||||
use crate::{
|
||||
api::{self, AuthorizedApi, UnauthorizedApi},
|
||||
components::credentials::*,
|
||||
Page,
|
||||
};
|
||||
|
||||
#[component]
|
||||
pub fn Login<F>(cx: Scope, api: UnauthorizedApi, on_success: F) -> impl IntoView
|
||||
where
|
||||
F: Fn(AuthorizedApi) + 'static + Clone,
|
||||
{
|
||||
let (login_error, set_login_error) = create_signal(cx, None::<String>);
|
||||
let (wait_for_response, set_wait_for_response) = create_signal(cx, false);
|
||||
|
||||
let login_action =
|
||||
create_action(cx, move |(email, password): &(String, String)| {
|
||||
log::debug!("Try to login with {email}");
|
||||
let email = email.to_string();
|
||||
let password = password.to_string();
|
||||
let credentials = Credentials { email, password };
|
||||
let on_success = on_success.clone();
|
||||
async move {
|
||||
set_wait_for_response.update(|w| *w = true);
|
||||
let result = api.login(&credentials).await;
|
||||
set_wait_for_response.update(|w| *w = false);
|
||||
match result {
|
||||
Ok(res) => {
|
||||
set_login_error.update(|e| *e = None);
|
||||
on_success(res);
|
||||
}
|
||||
Err(err) => {
|
||||
let msg = match err {
|
||||
api::Error::Fetch(js_err) => {
|
||||
format!("{js_err:?}")
|
||||
}
|
||||
api::Error::Api(err) => err.message,
|
||||
};
|
||||
error!(
|
||||
"Unable to login with {}: {msg}",
|
||||
credentials.email
|
||||
);
|
||||
set_login_error.update(|e| *e = Some(msg));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let disabled = Signal::derive(cx, move || wait_for_response.get());
|
||||
|
||||
view! { cx,
|
||||
<CredentialsForm
|
||||
title = "Please login to your account"
|
||||
action_label = "Login"
|
||||
action = login_action
|
||||
error = login_error.into()
|
||||
disabled
|
||||
/>
|
||||
<p>"Don't have an account?"</p>
|
||||
<A href=Page::Register.path()>"Register"</A>
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
pub mod home;
|
||||
pub mod login;
|
||||
pub mod register;
|
||||
|
||||
pub use self::{home::*, login::*, register::*};
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub enum Page {
|
||||
#[default]
|
||||
Home,
|
||||
Login,
|
||||
Register,
|
||||
}
|
||||
|
||||
impl Page {
|
||||
pub fn path(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Home => "/",
|
||||
Self::Login => "/login",
|
||||
Self::Register => "/register",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
use leptos::*;
|
||||
use leptos_router::*;
|
||||
|
||||
use api_boundary::*;
|
||||
|
||||
use crate::{
|
||||
api::{self, UnauthorizedApi},
|
||||
components::credentials::*,
|
||||
Page,
|
||||
};
|
||||
|
||||
#[component]
|
||||
pub fn Register(cx: Scope, api: UnauthorizedApi) -> impl IntoView {
|
||||
let (register_response, set_register_response) =
|
||||
create_signal(cx, None::<()>);
|
||||
let (register_error, set_register_error) =
|
||||
create_signal(cx, None::<String>);
|
||||
let (wait_for_response, set_wait_for_response) = create_signal(cx, false);
|
||||
|
||||
let register_action =
|
||||
create_action(cx, move |(email, password): &(String, String)| {
|
||||
let email = email.to_string();
|
||||
let password = password.to_string();
|
||||
let credentials = Credentials { email, password };
|
||||
log!("Try to register new account for {}", credentials.email);
|
||||
async move {
|
||||
set_wait_for_response.update(|w| *w = true);
|
||||
let result = api.register(&credentials).await;
|
||||
set_wait_for_response.update(|w| *w = false);
|
||||
match result {
|
||||
Ok(res) => {
|
||||
set_register_response.update(|v| *v = Some(res));
|
||||
set_register_error.update(|e| *e = None);
|
||||
}
|
||||
Err(err) => {
|
||||
let msg = match err {
|
||||
api::Error::Fetch(js_err) => {
|
||||
format!("{js_err:?}")
|
||||
}
|
||||
api::Error::Api(err) => err.message,
|
||||
};
|
||||
log::warn!(
|
||||
"Unable to register new account for {}: {msg}",
|
||||
credentials.email
|
||||
);
|
||||
set_register_error.update(|e| *e = Some(msg));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let disabled = Signal::derive(cx, move || wait_for_response.get());
|
||||
|
||||
view! { cx,
|
||||
<Show
|
||||
when = move || register_response.get().is_some()
|
||||
fallback = move |_| view!{ cx,
|
||||
<CredentialsForm
|
||||
title = "Please enter the desired credentials"
|
||||
action_label = "Register"
|
||||
action = register_action
|
||||
error = register_error.into()
|
||||
disabled
|
||||
/>
|
||||
<p>"Your already have an account?"</p>
|
||||
<A href=Page::Login.path()>"Login"</A>
|
||||
}
|
||||
>
|
||||
<p>"You have successfully registered."</p>
|
||||
<p>
|
||||
"You can now "
|
||||
<A href=Page::Login.path()>"login"</A>
|
||||
" with your new account."
|
||||
</p>
|
||||
</Show>
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
[package]
|
||||
name = "server"
|
||||
version = "0.0.0"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0"
|
||||
api-boundary = "*"
|
||||
axum = { version = "0.6", features = ["headers"] }
|
||||
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"] }
|
||||
@@ -1,114 +0,0 @@
|
||||
use crate::{application::*, Error};
|
||||
use api_boundary as json;
|
||||
use axum::{
|
||||
http::StatusCode,
|
||||
response::Json,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use thiserror::Error;
|
||||
|
||||
impl From<InvalidEmailAddress> for json::Error {
|
||||
fn from(_: InvalidEmailAddress) -> Self {
|
||||
Self {
|
||||
message: "Invalid email address".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<InvalidPassword> for json::Error {
|
||||
fn from(err: InvalidPassword) -> Self {
|
||||
let InvalidPassword::TooShort(min_len) = err;
|
||||
Self {
|
||||
message: format!("Invalid password (min. length = {min_len})"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<CreateUserError> for json::Error {
|
||||
fn from(err: CreateUserError) -> Self {
|
||||
let message = match err {
|
||||
CreateUserError::UserExists => "User already exits".to_string(),
|
||||
};
|
||||
Self { message }
|
||||
}
|
||||
}
|
||||
|
||||
impl From<LoginError> for json::Error {
|
||||
fn from(err: LoginError) -> Self {
|
||||
let message = match err {
|
||||
LoginError::InvalidEmailOrPassword => {
|
||||
"Invalid email or password".to_string()
|
||||
}
|
||||
};
|
||||
Self { message }
|
||||
}
|
||||
}
|
||||
|
||||
impl From<LogoutError> for json::Error {
|
||||
fn from(err: LogoutError) -> Self {
|
||||
let message = match err {
|
||||
LogoutError::NotLoggedIn => "No user is logged in".to_string(),
|
||||
};
|
||||
Self { message }
|
||||
}
|
||||
}
|
||||
|
||||
impl From<AuthError> for json::Error {
|
||||
fn from(err: AuthError) -> Self {
|
||||
let message = match err {
|
||||
AuthError::NotAuthorized => "Not authorized".to_string(),
|
||||
};
|
||||
Self { message }
|
||||
}
|
||||
}
|
||||
|
||||
impl From<CredentialParsingError> for json::Error {
|
||||
fn from(err: CredentialParsingError) -> Self {
|
||||
match err {
|
||||
CredentialParsingError::EmailAddress(err) => err.into(),
|
||||
CredentialParsingError::Password(err) => err.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum CredentialParsingError {
|
||||
#[error(transparent)]
|
||||
EmailAddress(#[from] InvalidEmailAddress),
|
||||
#[error(transparent)]
|
||||
Password(#[from] InvalidPassword),
|
||||
}
|
||||
|
||||
impl TryFrom<json::Credentials> for Credentials {
|
||||
type Error = CredentialParsingError;
|
||||
fn try_from(
|
||||
json::Credentials { email, password }: json::Credentials,
|
||||
) -> Result<Self, Self::Error> {
|
||||
let email: EmailAddress = email.parse()?;
|
||||
let password = Password::try_from(password)?;
|
||||
Ok(Self { email, password })
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for Error {
|
||||
fn into_response(self) -> Response {
|
||||
let (code, value) = match self {
|
||||
Self::Logout(err) => {
|
||||
(StatusCode::BAD_REQUEST, json::Error::from(err))
|
||||
}
|
||||
Self::Login(err) => {
|
||||
(StatusCode::BAD_REQUEST, json::Error::from(err))
|
||||
}
|
||||
Self::Credentials(err) => {
|
||||
(StatusCode::BAD_REQUEST, json::Error::from(err))
|
||||
}
|
||||
Self::CreateUser(err) => {
|
||||
(StatusCode::BAD_REQUEST, json::Error::from(err))
|
||||
}
|
||||
Self::Auth(err) => {
|
||||
(StatusCode::UNAUTHORIZED, json::Error::from(err))
|
||||
}
|
||||
};
|
||||
(code, Json(value)).into_response()
|
||||
}
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
use mailparse::addrparse;
|
||||
use pwhash::bcrypt;
|
||||
use std::{collections::HashMap, str::FromStr, sync::RwLock};
|
||||
use thiserror::Error;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct AppState {
|
||||
users: RwLock<HashMap<EmailAddress, Password>>,
|
||||
tokens: RwLock<HashMap<Uuid, EmailAddress>>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn create_user(
|
||||
&self,
|
||||
credentials: Credentials,
|
||||
) -> Result<(), CreateUserError> {
|
||||
let Credentials { email, password } = credentials;
|
||||
let user_exists = self.users.read().unwrap().get(&email).is_some();
|
||||
if user_exists {
|
||||
return Err(CreateUserError::UserExists);
|
||||
}
|
||||
self.users.write().unwrap().insert(email, password);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn login(
|
||||
&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);
|
||||
if !valid_credentials {
|
||||
Err(LoginError::InvalidEmailOrPassword)
|
||||
} else {
|
||||
let token = Uuid::new_v4();
|
||||
self.tokens.write().unwrap().insert(token, email);
|
||||
Ok(token)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn logout(&self, token: &str) -> Result<(), LogoutError> {
|
||||
let token = token
|
||||
.parse::<Uuid>()
|
||||
.map_err(|_| LogoutError::NotLoggedIn)?;
|
||||
self.tokens.write().unwrap().remove(&token);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn authorize_user(
|
||||
&self,
|
||||
token: &str,
|
||||
) -> Result<CurrentUser, AuthError> {
|
||||
token
|
||||
.parse::<Uuid>()
|
||||
.map_err(|_| AuthError::NotAuthorized)
|
||||
.and_then(|token| {
|
||||
self.tokens
|
||||
.read()
|
||||
.unwrap()
|
||||
.get(&token)
|
||||
.cloned()
|
||||
.map(|email| CurrentUser { email, token })
|
||||
.ok_or(AuthError::NotAuthorized)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum CreateUserError {
|
||||
#[error("The user already exists")]
|
||||
UserExists,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum LoginError {
|
||||
#[error("Invalid email or password")]
|
||||
InvalidEmailOrPassword,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum LogoutError {
|
||||
#[error("You are not logged in")]
|
||||
NotLoggedIn,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum AuthError {
|
||||
#[error("You are not authorized")]
|
||||
NotAuthorized,
|
||||
}
|
||||
|
||||
pub struct Credentials {
|
||||
pub email: EmailAddress,
|
||||
pub password: Password,
|
||||
}
|
||||
|
||||
#[derive(Clone, Eq, PartialEq, Hash)]
|
||||
pub struct EmailAddress(String);
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
#[error("The given email address is invalid")]
|
||||
pub struct InvalidEmailAddress;
|
||||
|
||||
impl FromStr for EmailAddress {
|
||||
type Err = InvalidEmailAddress;
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
addrparse(s)
|
||||
.ok()
|
||||
.and_then(|parsed| parsed.extract_single_info())
|
||||
.map(|single_info| Self(single_info.addr))
|
||||
.ok_or(InvalidEmailAddress)
|
||||
}
|
||||
}
|
||||
|
||||
impl EmailAddress {
|
||||
pub fn into_string(self) -> String {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct CurrentUser {
|
||||
pub email: EmailAddress,
|
||||
pub token: Uuid,
|
||||
}
|
||||
|
||||
const MIN_PASSWORD_LEN: usize = 3;
|
||||
|
||||
pub struct Password(String);
|
||||
|
||||
impl Password {
|
||||
pub fn verify(&self, password: &str) -> bool {
|
||||
bcrypt::verify(password, &self.0)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum InvalidPassword {
|
||||
#[error("Password is too short (min. length is {0})")]
|
||||
TooShort(usize),
|
||||
}
|
||||
|
||||
impl TryFrom<String> for Password {
|
||||
type Error = InvalidPassword;
|
||||
fn try_from(p: String) -> Result<Self, Self::Error> {
|
||||
if p.len() < MIN_PASSWORD_LEN {
|
||||
return Err(InvalidPassword::TooShort(MIN_PASSWORD_LEN));
|
||||
}
|
||||
let hashed = bcrypt::hash(&p).unwrap();
|
||||
Ok(Self(hashed))
|
||||
}
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
use std::{env, sync::Arc};
|
||||
|
||||
use axum::{
|
||||
extract::{State, TypedHeader},
|
||||
headers::{authorization::Bearer, Authorization},
|
||||
http::Method,
|
||||
response::Json,
|
||||
routing::{get, post},
|
||||
Router,
|
||||
};
|
||||
use tower_http::cors::{Any, CorsLayer};
|
||||
|
||||
use api_boundary as json;
|
||||
|
||||
mod adapters;
|
||||
mod application;
|
||||
|
||||
use self::application::*;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
if let Err(err) = env::var("RUST_LOG") {
|
||||
match err {
|
||||
env::VarError::NotPresent => {
|
||||
env::set_var("RUST_LOG", "debug");
|
||||
}
|
||||
env::VarError::NotUnicode(_) => {
|
||||
return Err(anyhow::anyhow!("The value of 'RUST_LOG' does not contain valid unicode data."));
|
||||
}
|
||||
}
|
||||
}
|
||||
env_logger::init();
|
||||
|
||||
let shared_state = Arc::new(AppState::default());
|
||||
|
||||
let cors_layer = CorsLayer::new()
|
||||
.allow_methods([Method::GET, Method::POST])
|
||||
.allow_origin(Any);
|
||||
|
||||
let app = Router::new()
|
||||
.route("/login", post(login))
|
||||
.route("/logout", post(logout))
|
||||
.route("/users", post(create_user))
|
||||
.route("/users", get(get_user_info))
|
||||
.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?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
type Result<T> = std::result::Result<Json<T>, Error>;
|
||||
|
||||
/// API error
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
#[non_exhaustive]
|
||||
enum Error {
|
||||
#[error(transparent)]
|
||||
CreateUser(#[from] CreateUserError),
|
||||
#[error(transparent)]
|
||||
Login(#[from] LoginError),
|
||||
#[error(transparent)]
|
||||
Logout(#[from] LogoutError),
|
||||
#[error(transparent)]
|
||||
Auth(#[from] AuthError),
|
||||
#[error(transparent)]
|
||||
Credentials(#[from] adapters::CredentialParsingError),
|
||||
}
|
||||
|
||||
async fn create_user(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(credentials): Json<json::Credentials>,
|
||||
) -> Result<()> {
|
||||
let credentials = Credentials::try_from(credentials)?;
|
||||
state.create_user(credentials)?;
|
||||
Ok(Json(()))
|
||||
}
|
||||
|
||||
async fn login(
|
||||
State(state): State<Arc<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())?;
|
||||
Ok(Json(json::ApiToken { token }))
|
||||
}
|
||||
|
||||
async fn logout(
|
||||
State(state): State<Arc<AppState>>,
|
||||
TypedHeader(auth): TypedHeader<Authorization<Bearer>>,
|
||||
) -> Result<()> {
|
||||
state.logout(auth.token())?;
|
||||
Ok(Json(()))
|
||||
}
|
||||
|
||||
async fn get_user_info(
|
||||
State(state): State<Arc<AppState>>,
|
||||
TypedHeader(auth): TypedHeader<Authorization<Bearer>>,
|
||||
) -> Result<json::UserInfo> {
|
||||
let user = state.authorize_user(auth.token())?;
|
||||
let CurrentUser { email, .. } = user;
|
||||
Ok(Json(json::UserInfo {
|
||||
email: email.into_string(),
|
||||
}))
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
use flake
|
||||
@@ -1,113 +0,0 @@
|
||||
[package]
|
||||
name = "session_auth_axum"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0.66"
|
||||
console_log = "0.2.0"
|
||||
rand = { version = "0.8.5", features = ["min_const_gen"], optional = true }
|
||||
console_error_panic_hook = "0.1.7"
|
||||
futures = "0.3.25"
|
||||
cfg-if = "1.0.0"
|
||||
leptos = { version = "0.2.0", default-features = false, features = [
|
||||
"serde",
|
||||
] }
|
||||
leptos_meta = { version = "0.2.0", default-features = false }
|
||||
leptos_axum = { version = "0.2.0", optional = true }
|
||||
leptos_router = { version = "0.2.0", default-features = false }
|
||||
leptos_reactive = { version = "0.2.0", default-features = false }
|
||||
log = "0.4.17"
|
||||
simple_logger = "4.0.0"
|
||||
serde = { version = "1.0.148", features = ["derive"] }
|
||||
serde_json = "1.0.89"
|
||||
gloo-net = { version = "0.2.5", features = ["http"] }
|
||||
reqwest = { version = "0.11.13", features = ["json"] }
|
||||
axum = { version = "0.6.1", optional = true }
|
||||
tower = { version = "0.4.13", optional = true }
|
||||
tower-http = { version = "0.4", features = ["fs"], optional = true }
|
||||
tokio = { version = "1.22.0", features = ["full"], optional = true }
|
||||
http = { version = "0.2.8" }
|
||||
sqlx = { version = "0.6.2", features = [
|
||||
"runtime-tokio-rustls",
|
||||
"sqlite",
|
||||
], optional = true }
|
||||
thiserror = "1.0.38"
|
||||
tracing = "0.1.37"
|
||||
wasm-bindgen = "0.2"
|
||||
axum_sessions_auth = { version = "7.0.0", features = [ "sqlite-rustls" ], optional = true }
|
||||
axum_database_sessions = { version = "7.0.0", features = [ "sqlite-rustls" ], optional = true }
|
||||
bcrypt = { version = "0.14", optional = true }
|
||||
async-trait = {version = "0.1.64", optional = true }
|
||||
|
||||
[features]
|
||||
default = ["csr"]
|
||||
csr = ["leptos/csr", "leptos_meta/csr", "leptos_router/csr"]
|
||||
hydrate = ["leptos/hydrate", "leptos_meta/hydrate", "leptos_router/hydrate"]
|
||||
ssr = [
|
||||
"dep:axum",
|
||||
"dep:tower",
|
||||
"dep:tower-http",
|
||||
"dep:tokio",
|
||||
"dep:axum_sessions_auth",
|
||||
"dep:axum_database_sessions",
|
||||
"dep:async-trait",
|
||||
"dep:sqlx",
|
||||
"dep:bcrypt",
|
||||
"dep:rand",
|
||||
"leptos/ssr",
|
||||
"leptos_meta/ssr",
|
||||
"leptos_router/ssr",
|
||||
"dep:leptos_axum",
|
||||
]
|
||||
|
||||
[package.metadata.cargo-all-features]
|
||||
denylist = ["axum", "tower", "tower-http", "tokio", "sqlx", "leptos_axum"]
|
||||
skip_feature_sets = [["csr", "ssr"], ["csr", "hydrate"], ["ssr", "hydrate"]]
|
||||
|
||||
[package.metadata.leptos]
|
||||
# The name used by wasm-bindgen/cargo-leptos for the JS/WASM bundle. Defaults to the crate name
|
||||
output-name = "session_auth_axum"
|
||||
# The site root folder is where cargo-leptos generate all output. WARNING: all content of this folder will be erased on a rebuild. Use it in your server setup.
|
||||
site-root = "target/site"
|
||||
# The site-root relative folder where all compiled output (JS, WASM and CSS) is written
|
||||
# Defaults to pkg
|
||||
site-pkg-dir = "pkg"
|
||||
# [Optional] The source CSS file. If it ends with .sass or .scss then it will be compiled by dart-sass into CSS. The CSS is optimized by Lightning CSS before being written to <site-root>/<site-pkg>/app.css
|
||||
style-file = "./style.css"
|
||||
# [Optional] Files in the asset-dir will be copied to the site-root directory
|
||||
assets-dir = "public"
|
||||
# The IP and port (ex: 127.0.0.1:3000) where the server serves the content. Use it in your server setup.
|
||||
site-addr = "127.0.0.1:3000"
|
||||
# The port to use for automatic reload monitoring
|
||||
reload-port = 3001
|
||||
# [Optional] Command to use when running end2end tests. It will run in the end2end dir.
|
||||
end2end-cmd = "npx playwright test"
|
||||
# The browserlist query used for optimizing the CSS.
|
||||
browserquery = "defaults"
|
||||
# Set by cargo-leptos watch when building with tha tool. Controls whether autoreload JS will be included in the head
|
||||
watch = false
|
||||
# The environment Leptos will run in, usually either "DEV" or "PROD"
|
||||
env = "DEV"
|
||||
# The features to use when compiling the bin target
|
||||
#
|
||||
# Optional. Can be over-ridden with the command line parameter --bin-features
|
||||
bin-features = ["ssr"]
|
||||
|
||||
# If the --no-default-features flag should be used when compiling the bin target
|
||||
#
|
||||
# Optional. Defaults to false.
|
||||
bin-default-features = false
|
||||
|
||||
# The features to use when compiling the lib target
|
||||
#
|
||||
# Optional. Can be over-ridden with the command line parameter --lib-features
|
||||
lib-features = ["hydrate"]
|
||||
|
||||
# If the --no-default-features flag should be used when compiling the lib target
|
||||
#
|
||||
# Optional. Defaults to false.
|
||||
lib-default-features = false
|
||||
@@ -1,21 +0,0 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2022 Greg Johnston
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,9 +0,0 @@
|
||||
[tasks.build]
|
||||
command = "cargo"
|
||||
args = ["+nightly", "build-all-features"]
|
||||
install_crate = "cargo-all-features"
|
||||
|
||||
[tasks.check]
|
||||
command = "cargo"
|
||||
args = ["+nightly", "check-all-features"]
|
||||
install_crate = "cargo-all-features"
|
||||
@@ -1,42 +0,0 @@
|
||||
# Leptos Authenticated Todo App Sqlite with Axum
|
||||
|
||||
This example creates a basic todo app with an Axum backend that uses Leptos' server functions to call sqlx from the client and seamlessly run it on the server. It lets you login, signup, and submit todos as different users, or a guest.
|
||||
|
||||
## Client Side Rendering
|
||||
This example cannot be built as a trunk standalone CSR-only app. Only the server may directly connect to the database.
|
||||
|
||||
## Server Side Rendering with cargo-leptos
|
||||
cargo-leptos is now the easiest and most featureful way to build server side rendered apps with hydration. It provides automatic recompilation of client and server code, wasm optimisation, CSS minification, and more! Check out more about it [here](https://github.com/akesson/cargo-leptos)
|
||||
|
||||
1. Install cargo-leptos
|
||||
```bash
|
||||
cargo install --locked cargo-leptos
|
||||
```
|
||||
2. Build the site in watch mode, recompiling on file changes
|
||||
```bash
|
||||
cargo leptos watch
|
||||
```
|
||||
|
||||
Open browser on [http://localhost:3000/](http://localhost:3000/)
|
||||
|
||||
3. When ready to deploy, run
|
||||
```bash
|
||||
cargo leptos build --release
|
||||
```
|
||||
|
||||
## Server Side Rendering without cargo-leptos
|
||||
To run it as a server side app with hydration, you'll need to have wasm-pack installed.
|
||||
|
||||
0. Edit the `[package.metadata.leptos]` section and set `site-root` to `"."`. You'll also want to change the path of the `<StyleSheet / >` component in the root component to point towards the CSS file in the root. This tells leptos that the WASM/JS files generated by wasm-pack are available at `./pkg` and that the CSS files are no longer processed by cargo-leptos. Building to alternative folders is not supported at this time. You'll also want to edit the call to `get_configuration()` to pass in `Some(Cargo.toml)`, so that Leptos will read the settings instead of cargo-leptos. If you do so, your file/folder names cannot include dashes.
|
||||
1. Install wasm-pack
|
||||
```bash
|
||||
cargo install wasm-pack
|
||||
```
|
||||
2. Build the Webassembly used to hydrate the HTML from the server
|
||||
```bash
|
||||
wasm-pack build --target=web --debug --no-default-features --features=hydrate
|
||||
```
|
||||
3. Run the server to serve the Webassembly, JS, and HTML
|
||||
```bash
|
||||
cargo run --no-default-features --features=ssr
|
||||
```
|
||||
Binary file not shown.
80
examples/session_auth_axum/flake.lock
generated
80
examples/session_auth_axum/flake.lock
generated
@@ -1,80 +0,0 @@
|
||||
{
|
||||
"nodes": {
|
||||
"flake-utils": {
|
||||
"locked": {
|
||||
"lastModified": 1676283394,
|
||||
"narHash": "sha256-XX2f9c3iySLCw54rJ/CZs+ZK6IQy7GXNY4nSOyu2QG4=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "3db36a8b464d0c4532ba1c7dda728f4576d6d073",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-utils_2": {
|
||||
"locked": {
|
||||
"lastModified": 1659877975,
|
||||
"narHash": "sha256-zllb8aq3YO3h8B/U0/J1WBgAL8EX5yWf5pMj3G0NAmc=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "c0e246b9b83f637f4681389ecabcb2681b4f3af0",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1672580127,
|
||||
"narHash": "sha256-3lW3xZslREhJogoOkjeZtlBtvFMyxHku7I/9IVehhT8=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "0874168639713f547c05947c76124f78441ea46c",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-22.05",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": "nixpkgs",
|
||||
"rust-overlay": "rust-overlay"
|
||||
}
|
||||
},
|
||||
"rust-overlay": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils_2",
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1677292251,
|
||||
"narHash": "sha256-D+6q5Z2MQn3UFJtqsM5/AvVHi3NXKZTIMZt1JGq/spA=",
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"rev": "34cdbf6ad480ce13a6a526f57d8b9e609f3d65dc",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
{
|
||||
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-22.05";
|
||||
inputs.rust-overlay.url = "github:oxalica/rust-overlay";
|
||||
inputs.rust-overlay.inputs.nixpkgs.follows = "nixpkgs";
|
||||
inputs.flake-utils.url = "github:numtide/flake-utils";
|
||||
|
||||
outputs = { self, nixpkgs, rust-overlay, flake-utils }:
|
||||
flake-utils.lib.eachDefaultSystem (system:
|
||||
let
|
||||
pkgs = import nixpkgs {
|
||||
inherit system;
|
||||
overlays = [ (import rust-overlay) ];
|
||||
};
|
||||
in
|
||||
with pkgs; rec {
|
||||
devShells.default = mkShell {
|
||||
|
||||
shellHook = ''
|
||||
export PKG_CONFIG_PATH="${pkgs.openssl.dev}/lib/pkgconfig";
|
||||
'';
|
||||
|
||||
nativeBuildInputs = [
|
||||
pkg-config
|
||||
];
|
||||
buildInputs = [
|
||||
trunk
|
||||
sqlite
|
||||
sass
|
||||
openssl
|
||||
(rust-bin.nightly.latest.default.override {
|
||||
extensions = [ "rust-src" ];
|
||||
targets = [ "wasm32-unknown-unknown" ];
|
||||
})
|
||||
];
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password TEXT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_permissions (
|
||||
user_id INTEGER NOT NULL,
|
||||
token TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- INSERT INTO users (id, anonymous, username, password)
|
||||
-- SELECT 0, true, 'Guest', ''
|
||||
-- ON CONFLICT(id) DO UPDATE SET
|
||||
-- anonymous = EXCLUDED.anonymous,
|
||||
-- username = EXCLUDED.username;
|
||||
|
||||
|
||||
CREATE TABLE IF NOT EXISTS todos (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
completed BOOLEAN,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
-- FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB |
@@ -1,231 +0,0 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use cfg_if::cfg_if;
|
||||
use leptos::*;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
cfg_if! {
|
||||
if #[cfg(feature = "ssr")] {
|
||||
use sqlx::SqlitePool;
|
||||
use axum_sessions_auth::{SessionSqlitePool, Authentication, HasPermission};
|
||||
use bcrypt::{hash, verify, DEFAULT_COST};
|
||||
use crate::todo::{pool, auth};
|
||||
pub type AuthSession = axum_sessions_auth::AuthSession<User, i64, SessionSqlitePool, SqlitePool>;
|
||||
}}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct User {
|
||||
pub id: i64,
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
pub permissions: HashSet<String>,
|
||||
}
|
||||
|
||||
impl Default for User {
|
||||
fn default() -> Self {
|
||||
let permissions = HashSet::new();
|
||||
|
||||
Self {
|
||||
id: -1,
|
||||
username: "Guest".into(),
|
||||
password: "".into(),
|
||||
permissions,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cfg_if! {
|
||||
if #[cfg(feature = "ssr")] {
|
||||
use async_trait::async_trait;
|
||||
|
||||
impl User {
|
||||
pub async fn get(id: i64, pool: &SqlitePool) -> Option<Self> {
|
||||
let sqluser = sqlx::query_as::<_, SqlUser>("SELECT * FROM users WHERE id = ?")
|
||||
.bind(id)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.ok()?;
|
||||
|
||||
//lets just get all the tokens the user can use, we will only use the full permissions if modifing them.
|
||||
let sql_user_perms = sqlx::query_as::<_, SqlPermissionTokens>(
|
||||
"SELECT token FROM user_permissions WHERE user_id = ?;",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.ok()?;
|
||||
|
||||
Some(sqluser.into_user(Some(sql_user_perms)))
|
||||
}
|
||||
|
||||
pub async fn get_from_username(name: String, pool: &SqlitePool) -> Option<Self> {
|
||||
let sqluser = sqlx::query_as::<_, SqlUser>("SELECT * FROM users WHERE username = ?")
|
||||
.bind(name)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.ok()?;
|
||||
|
||||
//lets just get all the tokens the user can use, we will only use the full permissions if modifing them.
|
||||
let sql_user_perms = sqlx::query_as::<_, SqlPermissionTokens>(
|
||||
"SELECT token FROM user_permissions WHERE user_id = ?;",
|
||||
)
|
||||
.bind(sqluser.id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.ok()?;
|
||||
|
||||
Some(sqluser.into_user(Some(sql_user_perms)))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow, Clone)]
|
||||
pub struct SqlPermissionTokens {
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Authentication<User, i64, SqlitePool> for User {
|
||||
async fn load_user(userid: i64, pool: Option<&SqlitePool>) -> Result<User, anyhow::Error> {
|
||||
let pool = pool.unwrap();
|
||||
|
||||
User::get(userid, pool)
|
||||
.await
|
||||
.ok_or_else(|| anyhow::anyhow!("Cannot get user"))
|
||||
}
|
||||
|
||||
fn is_authenticated(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn is_active(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn is_anonymous(&self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl HasPermission<SqlitePool> for User {
|
||||
async fn has(&self, perm: &str, _pool: &Option<&SqlitePool>) -> bool {
|
||||
self.permissions.contains(perm)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow, Clone)]
|
||||
pub struct SqlUser {
|
||||
pub id: i64,
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
impl SqlUser {
|
||||
pub fn into_user(self, sql_user_perms: Option<Vec<SqlPermissionTokens>>) -> User {
|
||||
User {
|
||||
id: self.id,
|
||||
username: self.username,
|
||||
password: self.password,
|
||||
permissions: if let Some(user_perms) = sql_user_perms {
|
||||
user_perms
|
||||
.into_iter()
|
||||
.map(|x| x.token)
|
||||
.collect::<HashSet<String>>()
|
||||
} else {
|
||||
HashSet::<String>::new()
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[server(Foo, "/api")]
|
||||
pub async fn foo() -> Result<String, ServerFnError> {
|
||||
Ok(String::from("Bar!"))
|
||||
}
|
||||
|
||||
#[server(GetUser, "/api")]
|
||||
pub async fn get_user(cx: Scope) -> Result<Option<User>, ServerFnError> {
|
||||
let auth = auth(cx)?;
|
||||
|
||||
Ok(auth.current_user)
|
||||
}
|
||||
|
||||
#[server(Login, "/api")]
|
||||
pub async fn login(
|
||||
cx: Scope,
|
||||
username: String,
|
||||
password: String,
|
||||
remember: Option<String>,
|
||||
) -> Result<(), ServerFnError> {
|
||||
let pool = pool(cx)?;
|
||||
let auth = auth(cx)?;
|
||||
|
||||
let user: User = User::get_from_username(username, &pool)
|
||||
.await
|
||||
.ok_or("User does not exist.")
|
||||
.map_err(|e| ServerFnError::ServerError(e.to_string()))?;
|
||||
|
||||
match verify(password, &user.password).map_err(|e| ServerFnError::ServerError(e.to_string()))? {
|
||||
true => {
|
||||
auth.login_user(user.id);
|
||||
auth.remember_user(remember.is_some());
|
||||
leptos_axum::redirect(cx, "/");
|
||||
Ok(())
|
||||
}
|
||||
false => Err(ServerFnError::ServerError(
|
||||
"Password does not match.".to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
#[server(Signup, "/api")]
|
||||
pub async fn signup(
|
||||
cx: Scope,
|
||||
username: String,
|
||||
password: String,
|
||||
password_confirmation: String,
|
||||
remember: Option<String>,
|
||||
) -> Result<(), ServerFnError> {
|
||||
let pool = pool(cx)?;
|
||||
let auth = auth(cx)?;
|
||||
|
||||
if password != password_confirmation {
|
||||
return Err(ServerFnError::ServerError(
|
||||
"Passwords did not match.".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let password_hashed = hash(password, DEFAULT_COST).unwrap();
|
||||
|
||||
sqlx::query("INSERT INTO users (username, password) VALUES (?,?)")
|
||||
.bind(username.clone())
|
||||
.bind(password_hashed)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.map_err(|e| ServerFnError::ServerError(e.to_string()))?;
|
||||
|
||||
let user = User::get_from_username(username, &pool)
|
||||
.await
|
||||
.ok_or("Signup failed: User does not exist.")
|
||||
.map_err(|e| ServerFnError::ServerError(e.to_string()))?;
|
||||
|
||||
auth.login_user(user.id);
|
||||
auth.remember_user(remember.is_some());
|
||||
|
||||
leptos_axum::redirect(cx, "/");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[server(Logout, "/api")]
|
||||
pub async fn logout(cx: Scope) -> Result<(), ServerFnError> {
|
||||
let auth = auth(cx)?;
|
||||
|
||||
auth.logout_user();
|
||||
leptos_axum::redirect(cx, "/");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
use crate::errors::TodoAppError;
|
||||
use cfg_if::cfg_if;
|
||||
use leptos::{Errors, *};
|
||||
#[cfg(feature = "ssr")]
|
||||
use leptos_axum::ResponseOptions;
|
||||
|
||||
// A basic function to display errors served by the error boundaries. Feel free to do more complicated things
|
||||
// here than just displaying them
|
||||
#[component]
|
||||
pub fn ErrorTemplate(
|
||||
cx: Scope,
|
||||
#[prop(optional)] outside_errors: Option<Errors>,
|
||||
#[prop(optional)] errors: Option<RwSignal<Errors>>,
|
||||
) -> impl IntoView {
|
||||
let errors = match outside_errors {
|
||||
Some(e) => create_rw_signal(cx, e),
|
||||
None => match errors {
|
||||
Some(e) => e,
|
||||
None => panic!("No Errors found and we expected errors!"),
|
||||
},
|
||||
};
|
||||
|
||||
// Get Errors from Signal
|
||||
// Downcast lets us take a type that implements `std::error::Error`
|
||||
let errors: Vec<TodoAppError> = errors
|
||||
.get()
|
||||
.into_iter()
|
||||
.filter_map(|(_, v)| v.downcast_ref::<TodoAppError>().cloned())
|
||||
.collect();
|
||||
|
||||
// Only the response code for the first error is actually sent from the server
|
||||
// this may be customized by the specific application
|
||||
cfg_if! {
|
||||
if #[cfg(feature="ssr")]{
|
||||
let response = use_context::<ResponseOptions>(cx);
|
||||
if let Some(response) = response{
|
||||
response.set_status(errors[0].status_code());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
view! {cx,
|
||||
<h1>"Errors"</h1>
|
||||
<For
|
||||
// a function that returns the items we're iterating over; a signal is fine
|
||||
each= move || {errors.clone().into_iter().enumerate()}
|
||||
// a unique key for each item as a reference
|
||||
key=|(index, _error)| *index
|
||||
// renders each item to a view
|
||||
view= move |cx, error| {
|
||||
let error_string = error.1.to_string();
|
||||
let error_code= error.1.status_code();
|
||||
view! {
|
||||
cx,
|
||||
<h2>{error_code.to_string()}</h2>
|
||||
<p>"Error: " {error_string}</p>
|
||||
}
|
||||
}
|
||||
/>
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
use http::status::StatusCode;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Clone, Error)]
|
||||
pub enum TodoAppError {
|
||||
#[error("Not Found")]
|
||||
NotFound,
|
||||
#[error("Internal Server Error")]
|
||||
InternalServerError,
|
||||
}
|
||||
|
||||
impl TodoAppError {
|
||||
pub fn status_code(&self) -> StatusCode {
|
||||
match self {
|
||||
TodoAppError::NotFound => StatusCode::NOT_FOUND,
|
||||
TodoAppError::InternalServerError => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
use cfg_if::cfg_if;
|
||||
|
||||
cfg_if! {
|
||||
if #[cfg(feature = "ssr")] {
|
||||
use axum::{
|
||||
body::{boxed, Body, BoxBody},
|
||||
extract::Extension,
|
||||
response::IntoResponse,
|
||||
http::{Request, Response, StatusCode, Uri},
|
||||
};
|
||||
use axum::response::Response as AxumResponse;
|
||||
use tower::ServiceExt;
|
||||
use tower_http::services::ServeDir;
|
||||
use std::sync::Arc;
|
||||
use leptos::{LeptosOptions, Errors, view};
|
||||
use crate::error_template::{ErrorTemplate, ErrorTemplateProps};
|
||||
use crate::errors::TodoAppError;
|
||||
|
||||
pub async fn file_and_error_handler(uri: Uri, Extension(options): Extension<Arc<LeptosOptions>>, req: Request<Body>) -> AxumResponse {
|
||||
let options = &*options;
|
||||
let root = options.site_root.clone();
|
||||
let res = get_static_file(uri.clone(), &root).await.unwrap();
|
||||
|
||||
if res.status() == StatusCode::OK {
|
||||
res.into_response()
|
||||
} else{
|
||||
let mut errors = Errors::default();
|
||||
errors.insert_with_default_key(TodoAppError::NotFound);
|
||||
let handler = leptos_axum::render_app_to_stream(options.to_owned(), move |cx| view!{cx, <ErrorTemplate outside_errors=errors.clone()/>});
|
||||
handler(req).await.into_response()
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_static_file(uri: Uri, root: &str) -> Result<Response<BoxBody>, (StatusCode, String)> {
|
||||
let req = Request::builder().uri(uri.clone()).body(Body::empty()).unwrap();
|
||||
// `ServeDir` implements `tower::Service` so we can call it with `tower::ServiceExt::oneshot`
|
||||
// This path is relative to the cargo root
|
||||
match ServeDir::new(root).oneshot(req).await {
|
||||
Ok(res) => Ok(res.map(boxed)),
|
||||
Err(err) => Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Something went wrong: {err}"),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
use cfg_if::cfg_if;
|
||||
|
||||
pub mod auth;
|
||||
pub mod error_template;
|
||||
pub mod errors;
|
||||
pub mod fallback;
|
||||
pub mod todo;
|
||||
|
||||
// Needs to be in lib.rs AFAIK because wasm-bindgen needs us to be compiling a lib. I may be wrong.
|
||||
cfg_if! {
|
||||
if #[cfg(feature = "hydrate")] {
|
||||
use wasm_bindgen::prelude::wasm_bindgen;
|
||||
use crate::todo::*;
|
||||
use leptos::view;
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn hydrate() {
|
||||
console_error_panic_hook::set_once();
|
||||
_ = console_log::init_with_level(log::Level::Debug);
|
||||
console_error_panic_hook::set_once();
|
||||
|
||||
leptos::mount_to_body(|cx| {
|
||||
view! { cx, <TodoApp/> }
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
use cfg_if::cfg_if;
|
||||
|
||||
// boilerplate to run in different modes
|
||||
cfg_if! {
|
||||
if #[cfg(feature = "ssr")] {
|
||||
use axum::{
|
||||
response::{Response, IntoResponse},
|
||||
routing::{post, get},
|
||||
extract::{Path, Extension},
|
||||
http::{Request, header::HeaderMap},
|
||||
body::Body as AxumBody,
|
||||
Router,
|
||||
};
|
||||
use session_auth_axum::todo::*;
|
||||
use session_auth_axum::auth::*;
|
||||
use session_auth_axum::*;
|
||||
use session_auth_axum::fallback::file_and_error_handler;
|
||||
use leptos_axum::{generate_route_list, LeptosRoutes, handle_server_fns_with_context};
|
||||
use leptos::{log, view, provide_context, LeptosOptions, get_configuration, ServerFnError};
|
||||
use std::sync::Arc;
|
||||
use sqlx::{SqlitePool, sqlite::SqlitePoolOptions};
|
||||
use axum_database_sessions::{SessionConfig, SessionLayer, SessionStore};
|
||||
use axum_sessions_auth::{AuthSessionLayer, AuthConfig, SessionSqlitePool};
|
||||
|
||||
async fn server_fn_handler(Extension(pool): Extension<SqlitePool>, auth_session: AuthSession, path: Path<String>, headers: HeaderMap, request: Request<AxumBody>) -> impl IntoResponse {
|
||||
|
||||
log!("{:?}", path);
|
||||
|
||||
handle_server_fns_with_context(path, headers, move |cx| {
|
||||
provide_context(cx, auth_session.clone());
|
||||
provide_context(cx, pool.clone());
|
||||
}, request).await
|
||||
}
|
||||
|
||||
async fn leptos_routes_handler(Extension(pool): Extension<SqlitePool>, auth_session: AuthSession, Extension(options): Extension<Arc<LeptosOptions>>, req: Request<AxumBody>) -> Response{
|
||||
let handler = leptos_axum::render_app_to_stream_with_context((*options).clone(),
|
||||
move |cx| {
|
||||
provide_context(cx, auth_session.clone());
|
||||
provide_context(cx, pool.clone());
|
||||
},
|
||||
|cx| view! { cx, <TodoApp/> }
|
||||
);
|
||||
handler(req).await.into_response()
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
simple_logger::init_with_level(log::Level::Info).expect("couldn't initialize logging");
|
||||
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.connect("sqlite:Todos.db")
|
||||
.await
|
||||
.expect("Could not make pool.");
|
||||
|
||||
// Auth section
|
||||
let session_config = SessionConfig::default().with_table_name("axum_sessions");
|
||||
let auth_config = AuthConfig::<i64>::default();
|
||||
let session_store = SessionStore::<SessionSqlitePool>::new(Some(pool.clone().into()), session_config);
|
||||
session_store.initiate().await.unwrap();
|
||||
|
||||
sqlx::migrate!()
|
||||
.run(&pool)
|
||||
.await
|
||||
.expect("could not run SQLx migrations");
|
||||
|
||||
crate::todo::register_server_functions();
|
||||
|
||||
// Setting this to None means we'll be using cargo-leptos and its env vars
|
||||
let conf = get_configuration(None).await.unwrap();
|
||||
let leptos_options = conf.leptos_options;
|
||||
let addr = leptos_options.site_addr;
|
||||
let routes = generate_route_list(|cx| view! { cx, <TodoApp/> }).await;
|
||||
|
||||
// build our application with a route
|
||||
let app = Router::new()
|
||||
.route("/api/*fn_name", post(server_fn_handler))
|
||||
.leptos_routes_with_handler(routes, get(leptos_routes_handler) )
|
||||
.fallback(file_and_error_handler)
|
||||
.layer(AuthSessionLayer::<User, i64, SessionSqlitePool, SqlitePool>::new(Some(pool.clone()))
|
||||
.with_config(auth_config))
|
||||
.layer(SessionLayer::new(session_store))
|
||||
.layer(Extension(Arc::new(leptos_options)))
|
||||
.layer(Extension(pool));
|
||||
|
||||
// run our app with hyper
|
||||
// `axum::Server` is a re-export of `hyper::Server`
|
||||
log!("listening on http://{}", &addr);
|
||||
axum::Server::bind(&addr)
|
||||
.serve(app.into_make_service())
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
// client-only stuff for Trunk
|
||||
else {
|
||||
pub fn main() {
|
||||
// This example cannot be built as a trunk standalone CSR-only app.
|
||||
// Only the server may directly connect to the database.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,375 +0,0 @@
|
||||
use crate::auth::*;
|
||||
use crate::error_template::{ErrorTemplate, ErrorTemplateProps};
|
||||
use cfg_if::cfg_if;
|
||||
use leptos::*;
|
||||
use leptos_meta::*;
|
||||
use leptos_router::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Todo {
|
||||
id: u32,
|
||||
user: Option<User>,
|
||||
title: String,
|
||||
created_at: String,
|
||||
completed: bool,
|
||||
}
|
||||
|
||||
cfg_if! {
|
||||
if #[cfg(feature = "ssr")] {
|
||||
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
pub fn pool(cx: Scope) -> Result<SqlitePool, ServerFnError> {
|
||||
Ok(use_context::<SqlitePool>(cx)
|
||||
.ok_or("Pool missing.")
|
||||
.map_err(|e| ServerFnError::ServerError(e.to_string()))?)
|
||||
}
|
||||
|
||||
pub fn auth(cx: Scope) -> Result<AuthSession, ServerFnError> {
|
||||
Ok(use_context::<AuthSession>(cx)
|
||||
.ok_or("Auth session missing.")
|
||||
.map_err(|e| ServerFnError::ServerError(e.to_string()))?)
|
||||
}
|
||||
|
||||
pub fn register_server_functions() {
|
||||
_ = GetTodos::register();
|
||||
_ = AddTodo::register();
|
||||
_ = DeleteTodo::register();
|
||||
_ = Login::register();
|
||||
_ = Logout::register();
|
||||
_ = Signup::register();
|
||||
_ = GetUser::register();
|
||||
_ = Foo::register();
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow, Clone)]
|
||||
pub struct SqlTodo {
|
||||
id: u32,
|
||||
user_id: i64,
|
||||
title: String,
|
||||
created_at: String,
|
||||
completed: bool,
|
||||
}
|
||||
|
||||
impl SqlTodo {
|
||||
pub async fn into_todo(self, pool: &SqlitePool) -> Todo {
|
||||
Todo {
|
||||
id: self.id,
|
||||
user: User::get(self.user_id, pool).await,
|
||||
title: self.title,
|
||||
created_at: self.created_at,
|
||||
completed: self.completed,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[server(GetTodos, "/api")]
|
||||
pub async fn get_todos(cx: Scope) -> Result<Vec<Todo>, ServerFnError> {
|
||||
use futures::TryStreamExt;
|
||||
|
||||
let pool = pool(cx)?;
|
||||
|
||||
let mut todos = Vec::new();
|
||||
let mut rows = sqlx::query_as::<_, SqlTodo>("SELECT * FROM todos").fetch(&pool);
|
||||
|
||||
while let Some(row) = rows
|
||||
.try_next()
|
||||
.await
|
||||
.map_err(|e| ServerFnError::ServerError(e.to_string()))?
|
||||
{
|
||||
todos.push(row);
|
||||
}
|
||||
|
||||
// why can't we just have async closures?
|
||||
// let mut rows: Vec<Todo> = rows.iter().map(|t| async { t }).collect();
|
||||
|
||||
let mut converted_todos = Vec::with_capacity(todos.len());
|
||||
|
||||
for t in todos {
|
||||
let todo = t.into_todo(&pool).await;
|
||||
converted_todos.push(todo);
|
||||
}
|
||||
|
||||
let todos: Vec<Todo> = converted_todos;
|
||||
|
||||
Ok(todos)
|
||||
}
|
||||
|
||||
#[server(AddTodo, "/api")]
|
||||
pub async fn add_todo(cx: Scope, title: String) -> Result<(), ServerFnError> {
|
||||
let user = get_user(cx).await?;
|
||||
let pool = pool(cx)?;
|
||||
|
||||
let id = match user {
|
||||
Some(user) => user.id,
|
||||
None => -1,
|
||||
};
|
||||
|
||||
// fake API delay
|
||||
std::thread::sleep(std::time::Duration::from_millis(1250));
|
||||
|
||||
match sqlx::query("INSERT INTO todos (title, user_id, completed) VALUES (?, ?, false)")
|
||||
.bind(title)
|
||||
.bind(id)
|
||||
.execute(&pool)
|
||||
.await
|
||||
{
|
||||
Ok(_row) => Ok(()),
|
||||
Err(e) => Err(ServerFnError::ServerError(e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
#[server(DeleteTodo, "/api")]
|
||||
pub async fn delete_todo(cx: Scope, id: u16) -> Result<(), ServerFnError> {
|
||||
let pool = pool(cx)?;
|
||||
|
||||
sqlx::query("DELETE FROM todos WHERE id = $1")
|
||||
.bind(id)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(|e| ServerFnError::ServerError(e.to_string()))
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn TodoApp(cx: Scope) -> impl IntoView {
|
||||
let login = create_server_action::<Login>(cx);
|
||||
let logout = create_server_action::<Logout>(cx);
|
||||
let signup = create_server_action::<Signup>(cx);
|
||||
|
||||
let user = create_resource(
|
||||
cx,
|
||||
move || {
|
||||
(
|
||||
login.version().get(),
|
||||
signup.version().get(),
|
||||
logout.version().get(),
|
||||
)
|
||||
},
|
||||
move |_| get_user(cx),
|
||||
);
|
||||
provide_meta_context(cx);
|
||||
|
||||
view! {
|
||||
cx,
|
||||
<Link rel="shortcut icon" type_="image/ico" href="/favicon.ico"/>
|
||||
<Stylesheet id="leptos" href="/pkg/session_auth_axum.css"/>
|
||||
<Router>
|
||||
<header>
|
||||
<A href="/"><h1>"My Tasks"</h1></A>
|
||||
<Transition
|
||||
fallback=move || view! {cx, <span>"Loading..."</span>}
|
||||
>
|
||||
{move || {
|
||||
user.read(cx).map(|user| match user {
|
||||
Err(e) => view! {cx,
|
||||
<A href="/signup">"Signup"</A>", "
|
||||
<A href="/login">"Login"</A>", "
|
||||
<span>{format!("Login error: {}", e.to_string())}</span>
|
||||
}.into_view(cx),
|
||||
Ok(None) => view! {cx,
|
||||
<A href="/signup">"Signup"</A>", "
|
||||
<A href="/login">"Login"</A>", "
|
||||
<span>"Logged out."</span>
|
||||
}.into_view(cx),
|
||||
Ok(Some(user)) => view! {cx,
|
||||
<A href="/settings">"Settings"</A>", "
|
||||
<span>{format!("Logged in as: {} ({})", user.username, user.id)}</span>
|
||||
}.into_view(cx)
|
||||
})
|
||||
}}
|
||||
</Transition>
|
||||
</header>
|
||||
<hr/>
|
||||
<main>
|
||||
<Routes>
|
||||
<Route path="" view=|cx| view! {
|
||||
cx,
|
||||
<ErrorBoundary fallback=|cx, errors| view!{cx, <ErrorTemplate errors=errors/>}>
|
||||
<Todos/>
|
||||
</ErrorBoundary>
|
||||
}/> //Route
|
||||
<Route path="signup" view=move |cx| view! {
|
||||
cx,
|
||||
<Signup action=signup/>
|
||||
}/>
|
||||
<Route path="login" view=move |cx| view! {
|
||||
cx,
|
||||
<Login action=login />
|
||||
}/>
|
||||
<Route path="settings" view=move |cx| view! {
|
||||
cx,
|
||||
<h1>"Settings"</h1>
|
||||
<Logout action=logout />
|
||||
}/>
|
||||
</Routes>
|
||||
</main>
|
||||
</Router>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn Todos(cx: Scope) -> impl IntoView {
|
||||
let add_todo = create_server_multi_action::<AddTodo>(cx);
|
||||
let delete_todo = create_server_action::<DeleteTodo>(cx);
|
||||
let submissions = add_todo.submissions();
|
||||
|
||||
// list of todos is loaded from the server in reaction to changes
|
||||
let todos = create_resource(
|
||||
cx,
|
||||
move || (add_todo.version().get(), delete_todo.version().get()),
|
||||
move |_| get_todos(cx),
|
||||
);
|
||||
|
||||
view! {
|
||||
cx,
|
||||
<div>
|
||||
<MultiActionForm action=add_todo>
|
||||
<label>
|
||||
"Add a Todo"
|
||||
<input type="text" name="title"/>
|
||||
</label>
|
||||
<input type="submit" value="Add"/>
|
||||
</MultiActionForm>
|
||||
<Transition fallback=move || view! {cx, <p>"Loading..."</p> }>
|
||||
{move || {
|
||||
let existing_todos = {
|
||||
move || {
|
||||
todos.read(cx)
|
||||
.map(move |todos| match todos {
|
||||
Err(e) => {
|
||||
vec![view! { cx, <pre class="error">"Server Error: " {e.to_string()}</pre>}.into_any()]
|
||||
}
|
||||
Ok(todos) => {
|
||||
if todos.is_empty() {
|
||||
vec![view! { cx, <p>"No tasks were found."</p> }.into_any()]
|
||||
} else {
|
||||
todos
|
||||
.into_iter()
|
||||
.map(move |todo| {
|
||||
view! {
|
||||
cx,
|
||||
<li>
|
||||
{todo.title}
|
||||
": Created at "
|
||||
{todo.created_at}
|
||||
" by "
|
||||
{
|
||||
todo.user.unwrap_or_default().username
|
||||
}
|
||||
<ActionForm action=delete_todo>
|
||||
<input type="hidden" name="id" value={todo.id}/>
|
||||
<input type="submit" value="X"/>
|
||||
</ActionForm>
|
||||
</li>
|
||||
}
|
||||
.into_any()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
}
|
||||
}
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
};
|
||||
|
||||
let pending_todos = move || {
|
||||
submissions
|
||||
.get()
|
||||
.into_iter()
|
||||
.filter(|submission| submission.pending().get())
|
||||
.map(|submission| {
|
||||
view! {
|
||||
cx,
|
||||
<li class="pending">{move || submission.input.get().map(|data| data.title) }</li>
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
|
||||
view! {
|
||||
cx,
|
||||
<ul>
|
||||
{existing_todos}
|
||||
{pending_todos}
|
||||
</ul>
|
||||
}
|
||||
}
|
||||
}
|
||||
</Transition>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn Login(cx: Scope, action: Action<Login, Result<(), ServerFnError>>) -> impl IntoView {
|
||||
view! {
|
||||
cx,
|
||||
<ActionForm action=action>
|
||||
<h1>"Log In"</h1>
|
||||
<label>
|
||||
"User ID:"
|
||||
<input type="text" placeholder="User ID" maxlength="32" name="username" class="auth-input" />
|
||||
</label>
|
||||
<br/>
|
||||
<label>
|
||||
"Password:"
|
||||
<input type="password" placeholder="Password" name="password" class="auth-input" />
|
||||
</label>
|
||||
<br/>
|
||||
<label>
|
||||
<input type="checkbox" name="remember" class="auth-input" />
|
||||
"Remember me?"
|
||||
</label>
|
||||
<br/>
|
||||
<button type="submit" class="button">"Log In"</button>
|
||||
</ActionForm>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn Signup(cx: Scope, action: Action<Signup, Result<(), ServerFnError>>) -> impl IntoView {
|
||||
view! {
|
||||
cx,
|
||||
<ActionForm action=action>
|
||||
<h1>"Sign Up"</h1>
|
||||
<label>
|
||||
"User ID:"
|
||||
<input type="text" placeholder="User ID" maxlength="32" name="username" class="auth-input" />
|
||||
</label>
|
||||
<br/>
|
||||
<label>
|
||||
"Password:"
|
||||
<input type="password" placeholder="Password" name="password" class="auth-input" />
|
||||
</label>
|
||||
<br/>
|
||||
<label>
|
||||
"Confirm Password:"
|
||||
<input type="password" placeholder="Password again" name="password_confirmation" class="auth-input" />
|
||||
</label>
|
||||
<br/>
|
||||
<label>
|
||||
"Remember me?"
|
||||
<input type="checkbox" name="remember" class="auth-input" />
|
||||
</label>
|
||||
|
||||
<br/>
|
||||
<button type="submit" class="button">"Sign Up"</button>
|
||||
</ActionForm>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn Logout(cx: Scope, action: Action<Logout, Result<(), ServerFnError>>) -> impl IntoView {
|
||||
view! {
|
||||
cx,
|
||||
<div id="loginbox">
|
||||
<ActionForm action=action>
|
||||
<button type="submit" class="button">"Log Out"</button>
|
||||
</ActionForm>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
.pending {
|
||||
color: purple;
|
||||
}
|
||||
|
||||
a {
|
||||
color: black;
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
[tasks.build]
|
||||
command = "cargo"
|
||||
args = ["+nightly", "build-all-features"]
|
||||
install_crate = "cargo-all-features"
|
||||
|
||||
[tasks.check]
|
||||
command = "cargo"
|
||||
args = ["+nightly", "check-all-features"]
|
||||
install_crate = "cargo-all-features"
|
||||
13
examples/ssr_modes_axum/.gitignore
vendored
13
examples/ssr_modes_axum/.gitignore
vendored
@@ -1,13 +0,0 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
pkg
|
||||
|
||||
# These are backup files generated by rustfmt
|
||||
**/*.rs.bk
|
||||
|
||||
# node e2e test tools and outputs
|
||||
node_modules/
|
||||
test-results/
|
||||
end2end/playwright-report/
|
||||
playwright/.cache/
|
||||
@@ -1,91 +0,0 @@
|
||||
[package]
|
||||
name = "ssr_modes_axum"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
console_error_panic_hook = "0.1"
|
||||
console_log = "0.2"
|
||||
cfg-if = "1"
|
||||
lazy_static = "1"
|
||||
leptos = { path = "../../leptos", default-features = false, features = [
|
||||
"serde",
|
||||
] }
|
||||
leptos_meta = { path = "../../meta", default-features = false }
|
||||
leptos_axum = { path = "../../integrations/axum", default-features = false, optional = true }
|
||||
leptos_router = { path = "../../router", default-features = false }
|
||||
log = "0.4"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
simple_logger = "4"
|
||||
thiserror = "1"
|
||||
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", features = ["time"], optional = true}
|
||||
wasm-bindgen = "0.2"
|
||||
|
||||
[features]
|
||||
hydrate = ["leptos/hydrate", "leptos_meta/hydrate", "leptos_router/hydrate"]
|
||||
ssr = [
|
||||
"dep:axum",
|
||||
"dep:tower",
|
||||
"dep:tower-http",
|
||||
"dep:tokio",
|
||||
"leptos/ssr",
|
||||
"leptos_meta/ssr",
|
||||
"leptos_router/ssr",
|
||||
"dep:leptos_axum",
|
||||
]
|
||||
|
||||
[package.metadata.leptos]
|
||||
# The name used by wasm-bindgen/cargo-leptos for the JS/WASM bundle. Defaults to the crate name
|
||||
output-name = "ssr_modes"
|
||||
# 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 = "assets"
|
||||
# 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
|
||||
@@ -1,21 +0,0 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2022 henrik
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,9 +0,0 @@
|
||||
[tasks.build]
|
||||
command = "cargo"
|
||||
args = ["+nightly", "build-all-features"]
|
||||
install_crate = "cargo-all-features"
|
||||
|
||||
[tasks.check]
|
||||
command = "cargo"
|
||||
args = ["+nightly", "check-all-features"]
|
||||
install_crate = "cargo-all-features"
|
||||
@@ -1,54 +0,0 @@
|
||||
# Server-Side Rendering Modes
|
||||
|
||||
This example shows the different "rendering modes" that can be used while server-side
|
||||
rendering an application:
|
||||
1. **Synchronous**: Serve an HTML shell that includes `fallback` for any `Suspense`. Load data on the client, replacing `fallback` once they're loaded.
|
||||
- *Pros*: App shell appears very quickly: great TTFB (time to first byte).
|
||||
- *Cons*: Resources load relatively slowly; you need to wait for JS + Wasm to load before even making a request.
|
||||
2. **Out-of-order streaming**: Serve an HTML shell that includes `fallback` for any `Suspense`. Load data on the **server**, streaming it down to the client as it resolves, and streaming down HTML for `Suspense` nodes.
|
||||
- *Pros*: Combines the best of **synchronous** and **`async`**, with a very fast shell and resources that begin loading on the server.
|
||||
- *Cons*: Requires JS for suspended fragments to appear in correct order. Weaker meta tag support when it depends on data that's under suspense (has already streamed down `<head>`)
|
||||
3. **In-order streaming**: Walk through the tree, returning HTML synchronously as in synchronous rendering and out-of-order streaming until you hit a `Suspense`. At that point, wait for all its data to load, then render it, then the rest of the tree.
|
||||
- *Pros*: Does not require JS for HTML to appear in correct order.
|
||||
- *Cons*: Loads the shell more slowly than out-of-order streaming or synchronous rendering because it needs to pause at every `Suspense`. Cannot begin hydration until the entire page has loaded, so earlier pieces
|
||||
of the page will not be interactive until the suspended chunks have loaded.
|
||||
4. **`async`**: Load all resources on the server. Wait until all data are loaded, and render HTML in one sweep.
|
||||
- *Pros*: Better handling for meta tags (because you know async data even before you render the `<head>`). Faster complete load than **synchronous** because async resources begin loading on server.
|
||||
- *Cons*: Slower load time/TTFB: you need to wait for all async resources to load before displaying anything on the client.
|
||||
|
||||
## Server Side Rendering with `cargo-leptos`
|
||||
`cargo-leptos` is now the easiest and most featureful way to build server side rendered apps with hydration. It provides automatic recompilation of client and server code, wasm optimisation, CSS minification, and more! Check out more about it [here](https://github.com/akesson/cargo-leptos)
|
||||
|
||||
1. Install cargo-leptos
|
||||
```bash
|
||||
cargo install --locked cargo-leptos
|
||||
```
|
||||
2. Build the site in watch mode, recompiling on file changes
|
||||
```bash
|
||||
cargo leptos watch
|
||||
```
|
||||
|
||||
Open browser on [http://localhost:3000/](http://localhost:3000/)
|
||||
|
||||
3. When ready to deploy, run
|
||||
```bash
|
||||
cargo leptos build --release
|
||||
```
|
||||
|
||||
## Server Side Rendering without cargo-leptos
|
||||
To run it as a server side app with hydration, you'll need to have wasm-pack installed.
|
||||
|
||||
0. Edit the `[package.metadata.leptos]` section and set `site-root` to `"."`. You'll also want to change the path of the `<StyleSheet / >` component in the root component to point towards the CSS file in the root. This tells leptos that the WASM/JS files generated by wasm-pack are available at `./pkg` and that the CSS files are no longer processed by cargo-leptos. Building to alternative folders is not supported at this time. You'll also want to edit the call to `get_configuration()` to pass in `Some(Cargo.toml)`, so that Leptos will read the settings instead of cargo-leptos. If you do so, your file/folder names cannot include dashes.
|
||||
1. Install wasm-pack
|
||||
```bash
|
||||
cargo install wasm-pack
|
||||
```
|
||||
2. Build the Webassembly used to hydrate the HTML from the server
|
||||
```bash
|
||||
wasm-pack build --target=web --debug --no-default-features --features=hydrate
|
||||
```
|
||||
3. Run the server to serve the Webassembly, JS, and HTML
|
||||
```bash
|
||||
cargo run --no-default-features --features=ssr
|
||||
```
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB |
@@ -1,189 +0,0 @@
|
||||
use lazy_static::lazy_static;
|
||||
use leptos::*;
|
||||
use leptos_meta::*;
|
||||
use leptos_router::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
#[component]
|
||||
pub fn App(cx: Scope) -> impl IntoView {
|
||||
// Provides context that manages stylesheets, titles, meta tags, etc.
|
||||
provide_meta_context(cx);
|
||||
|
||||
view! { cx,
|
||||
<Stylesheet id="leptos" href="/pkg/ssr_modes.css"/>
|
||||
<Title text="Welcome to Leptos"/>
|
||||
|
||||
<Router>
|
||||
<main>
|
||||
<Routes>
|
||||
// We’ll load the home page with out-of-order streaming and <Suspense/>
|
||||
<Route path="" view=|cx| view! { cx, <HomePage/> }/>
|
||||
|
||||
// We'll load the posts with async rendering, so they can set
|
||||
// the title and metadata *after* loading the data
|
||||
<Route
|
||||
path="/post/:id"
|
||||
view=|cx| view! { cx, <Post/> }
|
||||
ssr=SsrMode::Async
|
||||
/>
|
||||
<Route
|
||||
path="/post_in_order/:id"
|
||||
view=|cx| view! { cx, <Post/> }
|
||||
ssr=SsrMode::InOrder
|
||||
/>
|
||||
</Routes>
|
||||
</main>
|
||||
</Router>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn HomePage(cx: Scope) -> impl IntoView {
|
||||
// load the posts
|
||||
let posts =
|
||||
create_resource(cx, || (), |_| async { list_post_metadata().await });
|
||||
let posts_view = move || {
|
||||
posts.with(cx, |posts| posts
|
||||
.clone()
|
||||
.map(|posts| {
|
||||
posts.iter()
|
||||
.map(|post| view! { cx, <li><a href=format!("/post/{}", post.id)>{&post.title}</a> "|" <a href=format!("/post_in_order/{}", post.id)>{&post.title}"(in order)"</a></li>})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
)
|
||||
};
|
||||
|
||||
view! { cx,
|
||||
<h1>"My Great Blog"</h1>
|
||||
<Suspense fallback=move || view! { cx, <p>"Loading posts..."</p> }>
|
||||
<ul>{posts_view}</ul>
|
||||
</Suspense>
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Params, Copy, Clone, Debug, PartialEq, Eq)]
|
||||
pub struct PostParams {
|
||||
id: usize,
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn Post(cx: Scope) -> impl IntoView {
|
||||
let query = use_params::<PostParams>(cx);
|
||||
let id = move || {
|
||||
query.with(|q| {
|
||||
q.as_ref().map(|q| q.id).map_err(|_| PostError::InvalidId)
|
||||
})
|
||||
};
|
||||
let post = create_resource(cx, id, |id| async move {
|
||||
match id {
|
||||
Err(e) => Err(e),
|
||||
Ok(id) => get_post(id)
|
||||
.await
|
||||
.map(|data| data.ok_or(PostError::PostNotFound))
|
||||
.map_err(|_| PostError::ServerError)
|
||||
.flatten(),
|
||||
}
|
||||
});
|
||||
|
||||
let post_view = move || {
|
||||
post.with(cx, |post| {
|
||||
post.clone().map(|post| {
|
||||
view! { cx,
|
||||
// render content
|
||||
<h1>{&post.title}</h1>
|
||||
<p>{&post.content}</p>
|
||||
|
||||
// since we're using async rendering for this page,
|
||||
// this metadata should be included in the actual HTML <head>
|
||||
// when it's first served
|
||||
<Title text=post.title/>
|
||||
<Meta name="description" content=post.content/>
|
||||
}
|
||||
})
|
||||
})
|
||||
};
|
||||
|
||||
view! { cx,
|
||||
<Suspense fallback=move || view! { cx, <p>"Loading post..."</p> }>
|
||||
<ErrorBoundary fallback=|cx, errors| {
|
||||
view! { cx,
|
||||
<div class="error">
|
||||
<h1>"Something went wrong."</h1>
|
||||
<ul>
|
||||
{move || errors.get()
|
||||
.into_iter()
|
||||
.map(|(_, error)| view! { cx, <li>{error.to_string()} </li> })
|
||||
.collect::<Vec<_>>()
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
}>
|
||||
{post_view}
|
||||
</ErrorBoundary>
|
||||
</Suspense>
|
||||
}
|
||||
}
|
||||
|
||||
// Dummy API
|
||||
lazy_static! {
|
||||
static ref POSTS: Vec<Post> = vec![
|
||||
Post {
|
||||
id: 0,
|
||||
title: "My first post".to_string(),
|
||||
content: "This is my first post".to_string(),
|
||||
},
|
||||
Post {
|
||||
id: 1,
|
||||
title: "My second post".to_string(),
|
||||
content: "This is my second post".to_string(),
|
||||
},
|
||||
Post {
|
||||
id: 2,
|
||||
title: "My third post".to_string(),
|
||||
content: "This is my third post".to_string(),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
#[derive(Error, Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum PostError {
|
||||
#[error("Invalid post ID.")]
|
||||
InvalidId,
|
||||
#[error("Post not found.")]
|
||||
PostNotFound,
|
||||
#[error("Server error.")]
|
||||
ServerError,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Post {
|
||||
id: usize,
|
||||
title: String,
|
||||
content: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PostMetadata {
|
||||
id: usize,
|
||||
title: String,
|
||||
}
|
||||
|
||||
#[server(ListPostMetadata, "/api")]
|
||||
pub async fn list_post_metadata() -> Result<Vec<PostMetadata>, ServerFnError> {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
Ok(POSTS
|
||||
.iter()
|
||||
.map(|data| PostMetadata {
|
||||
id: data.id,
|
||||
title: data.title.clone(),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[server(GetPost, "/api")]
|
||||
pub async fn get_post(id: usize) -> Result<Option<Post>, ServerFnError> {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
Ok(POSTS.iter().find(|post| post.id == id).cloned())
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
use cfg_if::cfg_if;
|
||||
|
||||
cfg_if! { if #[cfg(feature = "ssr")] {
|
||||
use axum::{
|
||||
body::{boxed, Body, BoxBody},
|
||||
extract::Extension,
|
||||
response::IntoResponse,
|
||||
http::{Request, Response, StatusCode, Uri},
|
||||
};
|
||||
use axum::response::Response as AxumResponse;
|
||||
use tower::ServiceExt;
|
||||
use tower_http::services::ServeDir;
|
||||
use std::sync::Arc;
|
||||
use leptos::{LeptosOptions, Errors, view};
|
||||
use crate::app::{App, AppProps};
|
||||
|
||||
pub async fn file_and_error_handler(uri: Uri, Extension(options): Extension<Arc<LeptosOptions>>, req: Request<Body>) -> AxumResponse {
|
||||
let options = &*options;
|
||||
let root = options.site_root.clone();
|
||||
let res = get_static_file(uri.clone(), &root).await.unwrap();
|
||||
|
||||
if res.status() == StatusCode::OK {
|
||||
res.into_response()
|
||||
} else{
|
||||
let handler = leptos_axum::render_app_to_stream(
|
||||
options.to_owned(),
|
||||
move |cx| view!{ cx, <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}"),
|
||||
)),
|
||||
}
|
||||
}
|
||||
}}
|
||||
@@ -1,26 +0,0 @@
|
||||
#![feature(result_flattening)]
|
||||
|
||||
pub mod app;
|
||||
pub mod fallback;
|
||||
use cfg_if::cfg_if;
|
||||
|
||||
cfg_if! {
|
||||
if #[cfg(feature = "hydrate")] {
|
||||
|
||||
use wasm_bindgen::prelude::wasm_bindgen;
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn hydrate() {
|
||||
use app::*;
|
||||
use leptos::*;
|
||||
|
||||
// initializes logging using the `log` crate
|
||||
_ = console_log::init_with_level(log::Level::Debug);
|
||||
console_error_panic_hook::set_once();
|
||||
|
||||
leptos::mount_to_body(move |cx| {
|
||||
view! { cx, <App/> }
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
#[cfg(feature = "ssr")]
|
||||
#[tokio::main]
|
||||
async fn main(){
|
||||
use leptos::*;
|
||||
use leptos_axum::{generate_route_list, LeptosRoutes};
|
||||
use axum::{extract::{Extension, Path}, Router, routing::{get, post}};
|
||||
use std::sync::Arc;
|
||||
use ssr_modes_axum::fallback::file_and_error_handler;
|
||||
use ssr_modes_axum::app::*;
|
||||
|
||||
let conf = get_configuration(None).await.unwrap();
|
||||
let addr = conf.leptos_options.site_addr;
|
||||
let leptos_options = conf.leptos_options;
|
||||
// Generate the list of routes in your Leptos App
|
||||
let routes = generate_route_list(|cx| view! { cx, <App/> }).await;
|
||||
|
||||
GetPost::register();
|
||||
ListPostMetadata::register();
|
||||
|
||||
let app = Router::new()
|
||||
.route("/api/*fn_name", post(leptos_axum::handle_server_fns))
|
||||
.leptos_routes(leptos_options.clone(), routes, |cx| view! { cx, <App/> })
|
||||
.fallback(file_and_error_handler)
|
||||
.layer(Extension(Arc::new(leptos_options)));
|
||||
|
||||
// run our app with hyper
|
||||
// `axum::Server` is a re-export of `hyper::Server`
|
||||
log!("listening on http://{}", &addr);
|
||||
axum::Server::bind(&addr)
|
||||
.serve(app.into_make_service())
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "ssr"))]
|
||||
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
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
body {
|
||||
font-family: sans-serif;
|
||||
}
|
||||
@@ -6,38 +6,32 @@ use leptos_router::*;
|
||||
pub fn App(cx: Scope) -> impl IntoView {
|
||||
provide_meta_context(cx);
|
||||
|
||||
let (count, set_count) = create_signal(cx, 0);
|
||||
|
||||
view! {
|
||||
cx,
|
||||
<Stylesheet id="leptos" href="/pkg/tailwind.css"/>
|
||||
<Link rel="shortcut icon" type_="image/ico" href="/favicon.ico"/>
|
||||
<Router>
|
||||
<Routes>
|
||||
<Route path="" view= move |cx| view! { cx, <Home/> }/>
|
||||
<Route path="" view= move |cx| view! {
|
||||
cx,
|
||||
<main class="my-0 mx-auto max-w-3xl text-center">
|
||||
<h2 class="p-6 text-4xl">"Welcome to Leptos with Tailwind"</h2>
|
||||
<p class="px-10 pb-10 text-left">"Tailwind will scan your Rust files for Tailwind class names and compile them into a CSS file."</p>
|
||||
<button
|
||||
class="bg-sky-600 hover:bg-sky-700 px-5 py-3 text-white rounded-lg"
|
||||
on:click=move |_| set_count.update(|count| *count += 1)
|
||||
>
|
||||
{move || if count() == 0 {
|
||||
"Click me!".to_string()
|
||||
} else {
|
||||
count().to_string()
|
||||
}}
|
||||
</button>
|
||||
</main>
|
||||
}/>
|
||||
</Routes>
|
||||
</Router>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn Home(cx: Scope) -> impl IntoView {
|
||||
let (count, set_count) = create_signal(cx, 0);
|
||||
|
||||
view! { cx,
|
||||
<main class="my-0 mx-auto max-w-3xl text-center">
|
||||
<h2 class="p-6 text-4xl">"Welcome to Leptos with Tailwind"</h2>
|
||||
<p class="px-10 pb-10 text-left">"Tailwind will scan your Rust files for Tailwind class names and compile them into a CSS file."</p>
|
||||
<button
|
||||
class="bg-amber-600 hover:bg-sky-700 px-5 py-3 text-white rounded-lg"
|
||||
on:click=move |_| set_count.update(|count| *count += 1)
|
||||
>
|
||||
"Something's here | "
|
||||
{move || if count() == 0 {
|
||||
"Click me!".to_string()
|
||||
} else {
|
||||
count().to_string()
|
||||
}}
|
||||
" | Some more text"
|
||||
</button>
|
||||
</main>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
! tailwindcss v3.2.4 | MIT License | https://tailwindcss.com
|
||||
! tailwindcss v3.1.8 | MIT License | https://tailwindcss.com
|
||||
*/
|
||||
|
||||
/*
|
||||
@@ -30,7 +30,6 @@
|
||||
2. Prevent adjustments of font size after orientation changes in iOS.
|
||||
3. Use a more readable tab size.
|
||||
4. Use the user's configured `sans` font-family by default.
|
||||
5. Use the user's configured `sans` font-feature-settings by default.
|
||||
*/
|
||||
|
||||
html {
|
||||
@@ -45,8 +44,6 @@ html {
|
||||
/* 3 */
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||
/* 4 */
|
||||
font-feature-settings: normal;
|
||||
/* 5 */
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -413,13 +410,54 @@ video {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/* Make elements with the HTML hidden attribute stay hidden by default */
|
||||
|
||||
[hidden] {
|
||||
display: none;
|
||||
*, ::before, ::after {
|
||||
--tw-border-spacing-x: 0;
|
||||
--tw-border-spacing-y: 0;
|
||||
--tw-translate-x: 0;
|
||||
--tw-translate-y: 0;
|
||||
--tw-rotate: 0;
|
||||
--tw-skew-x: 0;
|
||||
--tw-skew-y: 0;
|
||||
--tw-scale-x: 1;
|
||||
--tw-scale-y: 1;
|
||||
--tw-pan-x: ;
|
||||
--tw-pan-y: ;
|
||||
--tw-pinch-zoom: ;
|
||||
--tw-scroll-snap-strictness: proximity;
|
||||
--tw-ordinal: ;
|
||||
--tw-slashed-zero: ;
|
||||
--tw-numeric-figure: ;
|
||||
--tw-numeric-spacing: ;
|
||||
--tw-numeric-fraction: ;
|
||||
--tw-ring-inset: ;
|
||||
--tw-ring-offset-width: 0px;
|
||||
--tw-ring-offset-color: #fff;
|
||||
--tw-ring-color: rgb(59 130 246 / 0.5);
|
||||
--tw-ring-offset-shadow: 0 0 #0000;
|
||||
--tw-ring-shadow: 0 0 #0000;
|
||||
--tw-shadow: 0 0 #0000;
|
||||
--tw-shadow-colored: 0 0 #0000;
|
||||
--tw-blur: ;
|
||||
--tw-brightness: ;
|
||||
--tw-contrast: ;
|
||||
--tw-grayscale: ;
|
||||
--tw-hue-rotate: ;
|
||||
--tw-invert: ;
|
||||
--tw-saturate: ;
|
||||
--tw-sepia: ;
|
||||
--tw-drop-shadow: ;
|
||||
--tw-backdrop-blur: ;
|
||||
--tw-backdrop-brightness: ;
|
||||
--tw-backdrop-contrast: ;
|
||||
--tw-backdrop-grayscale: ;
|
||||
--tw-backdrop-hue-rotate: ;
|
||||
--tw-backdrop-invert: ;
|
||||
--tw-backdrop-opacity: ;
|
||||
--tw-backdrop-saturate: ;
|
||||
--tw-backdrop-sepia: ;
|
||||
}
|
||||
|
||||
*, ::before, ::after {
|
||||
::-webkit-backdrop {
|
||||
--tw-border-spacing-x: 0;
|
||||
--tw-border-spacing-y: 0;
|
||||
--tw-translate-x: 0;
|
||||
@@ -531,39 +569,11 @@ video {
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.rounded-md {
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
.rounded-sm {
|
||||
border-radius: 0.125rem;
|
||||
}
|
||||
|
||||
.bg-amber-600 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(217 119 6 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.bg-sky-600 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(2 132 199 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.bg-sky-300 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(125 211 252 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.bg-sky-500 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(14 165 233 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.bg-amber-500 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(245 158 11 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.p-6 {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
@@ -595,10 +605,6 @@ video {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.text-right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.text-4xl {
|
||||
font-size: 2.25rem;
|
||||
line-height: 2.5rem;
|
||||
@@ -609,41 +615,6 @@ video {
|
||||
color: rgb(255 255 255 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.text-red-500 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(239 68 68 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.text-red-200 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(254 202 202 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.text-sky-500 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(14 165 233 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.text-sky-300 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(125 211 252 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.text-sky-700 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(3 105 161 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.text-sky-800 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(7 89 133 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.text-red-800 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(153 27 27 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.hover\:bg-sky-700:hover {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(3 105 161 / var(--tw-bg-opacity));
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Leptos Todo App Sqlite
|
||||
|
||||
This example creates a basic todo app with an Actix backend that uses Leptos' server functions to call sqlx from the client and seamlessly run it on the server.
|
||||
This example creates a basic todo app with an Axum backend that uses Leptos' server functions to call sqlx from the client and seamlessly run it on the server.
|
||||
|
||||
## Client Side Rendering
|
||||
To run it as a Client Side App, you can issue `trunk serve --open` in the root. This will build the entire
|
||||
|
||||
@@ -129,20 +129,7 @@ pub fn Todos(cx: Scope) -> impl IntoView {
|
||||
view! {
|
||||
cx,
|
||||
<div>
|
||||
<MultiActionForm
|
||||
// we can handle client-side validation in the on:submit event
|
||||
// leptos_router implements a `FromFormData` trait that lets you
|
||||
// parse deserializable types from form data and check them
|
||||
on:submit=move |ev| {
|
||||
let data = AddTodo::from_event(&ev).expect("to parse form data");
|
||||
// silly example of validation: if the todo is "nope!", nope it
|
||||
if data.title == "nope!" {
|
||||
// ev.prevent_default() will prevent form submission
|
||||
ev.prevent_default();
|
||||
}
|
||||
}
|
||||
action=add_todo
|
||||
>
|
||||
<MultiActionForm action=add_todo>
|
||||
<label>
|
||||
"Add a Todo"
|
||||
<input type="text" name="title"/>
|
||||
|
||||
@@ -27,7 +27,7 @@ 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 }
|
||||
tower-http = { version = "0.3.4", features = ["fs"], optional = true }
|
||||
tokio = { version = "1.22.0", features = ["full"], optional = true }
|
||||
http = { version = "0.2.8" }
|
||||
sqlx = { version = "0.6.2", features = [
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
.pending {
|
||||
color: purple;
|
||||
}
|
||||
}
|
||||
@@ -143,7 +143,7 @@ pub fn TodoMVC(cx: Scope) -> impl IntoView {
|
||||
});
|
||||
|
||||
// Callback to add a todo on pressing the `Enter` key, if the field isn't empty
|
||||
let input_ref = create_node_ref::<Input>(cx);
|
||||
let input_ref = NodeRef::<Input>::new(cx);
|
||||
let add_todo = move |ev: web_sys::KeyboardEvent| {
|
||||
let input = input_ref.get().unwrap();
|
||||
ev.stop_propagation();
|
||||
@@ -273,8 +273,8 @@ pub fn Todo(cx: Scope, todo: Todo) -> impl IntoView {
|
||||
let (editing, set_editing) = create_signal(cx, false);
|
||||
let set_todos = use_context::<WriteSignal<Todos>>(cx).unwrap();
|
||||
|
||||
// this will be filled by node_ref=input below
|
||||
let todo_input = create_node_ref::<Input>(cx);
|
||||
// this will be filled by _ref=input below
|
||||
let todo_input = NodeRef::<Input>::new(cx);
|
||||
|
||||
let save = move |value: &str| {
|
||||
let value = value.trim();
|
||||
@@ -294,7 +294,7 @@ pub fn Todo(cx: Scope, todo: Todo) -> impl IntoView {
|
||||
>
|
||||
<div class="view">
|
||||
<input
|
||||
node_ref=todo_input
|
||||
_ref=todo_input
|
||||
class="toggle"
|
||||
type="checkbox"
|
||||
prop:checked={move || (todo.completed)()}
|
||||
|
||||
@@ -111,8 +111,6 @@ pub fn redirect(cx: leptos::Scope, path: &str) {
|
||||
|
||||
/// Decomposes an HTTP request into its parts, allowing you to read its headers
|
||||
/// and other data without consuming the body.
|
||||
#[deprecated(note = "Replaced with generate_request_and_parts() to allow for \
|
||||
putting LeptosRequest in the Context")]
|
||||
pub async fn generate_request_parts(req: Request<Body>) -> RequestParts {
|
||||
// provide request headers as context in server scope
|
||||
let (parts, body) = req.into_parts();
|
||||
@@ -126,88 +124,6 @@ pub async fn generate_request_parts(req: Request<Body>) -> RequestParts {
|
||||
}
|
||||
}
|
||||
|
||||
/// Decomposes an HTTP request into its parts, allowing you to read its headers
|
||||
/// and other data without consuming the body. Creates a new Request from the
|
||||
/// original parts for further processsing
|
||||
pub async fn generate_request_and_parts(
|
||||
req: Request<Body>,
|
||||
) -> (Request<Body>, RequestParts) {
|
||||
// provide request headers as context in server scope
|
||||
let (parts, body) = req.into_parts();
|
||||
let body = body::to_bytes(body).await.unwrap_or_default();
|
||||
let request_parts = RequestParts {
|
||||
method: parts.method.clone(),
|
||||
uri: parts.uri.clone(),
|
||||
headers: parts.headers.clone(),
|
||||
version: parts.version,
|
||||
body: body.clone(),
|
||||
};
|
||||
let request = Request::from_parts(parts, body.into());
|
||||
|
||||
(request, request_parts)
|
||||
}
|
||||
|
||||
/// A struct to hold the http::request::Request and allow users to take ownership of it
|
||||
/// Requred by Request not being Clone. See this issue for eventual resolution: https://github.com/hyperium/http/pull/574
|
||||
#[derive(Debug, Default)]
|
||||
pub struct LeptosRequest<B>(Arc<RwLock<Option<Request<B>>>>);
|
||||
|
||||
impl<B> Clone for LeptosRequest<B> {
|
||||
fn clone(&self) -> Self {
|
||||
Self(self.0.clone())
|
||||
}
|
||||
}
|
||||
impl<B> LeptosRequest<B> {
|
||||
/// Overwrite the contents of a LeptosRequest with a new Request<B>
|
||||
pub fn overwrite(&self, req: Option<Request<B>>) {
|
||||
let mut writable = self.0.write();
|
||||
*writable = req
|
||||
}
|
||||
/// Consume the inner Request<B> inside the LeptosRequest and return it
|
||||
///```rust, ignore
|
||||
/// use axum::{
|
||||
/// RequestPartsExt,
|
||||
/// headers::Host
|
||||
/// };
|
||||
/// #[server(GetHost, "/api")]
|
||||
/// pub async fn get_host(cx: Scope) -> Result((), ServerFnError){
|
||||
/// let req = use_context::<leptos_axum::LeptosRequest<axum::body::Body>>(cx);
|
||||
/// if let Some(req) = req{
|
||||
/// let owned_req = req.take_request().unwrap();
|
||||
/// let (mut parts, _body) = owned_req.into_parts();
|
||||
/// let host: TypedHeader<Host> = parts.extract().await().unwrap();
|
||||
/// println!("Host: {host:#?}");
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
pub fn take_request(&self) -> Option<Request<B>> {
|
||||
let mut writable = self.0.write();
|
||||
writable.take()
|
||||
}
|
||||
/// Can be used to get immutable access to the interior fields of Request
|
||||
/// and do something with them
|
||||
pub fn with(&self, with_fn: impl Fn(Option<&Request<B>>)) {
|
||||
let readable = self.0.read();
|
||||
with_fn(readable.as_ref());
|
||||
}
|
||||
|
||||
/// Can be used to mutate the fields of the Request
|
||||
pub fn update(&self, update_fn: impl Fn(Option<&mut Request<B>>)) {
|
||||
let mut writable = self.0.write();
|
||||
update_fn(writable.as_mut());
|
||||
}
|
||||
}
|
||||
/// Generate a wrapper for the http::Request::Request type that allows one to
|
||||
/// processs it, access the body, and use axum Extractors on it.
|
||||
/// Requred by Request not being Clone. See this issue for eventual resolution: https://github.com/hyperium/http/pull/574
|
||||
pub async fn generate_leptos_request<B>(req: Request<B>) -> LeptosRequest<B>
|
||||
where
|
||||
B: Default + std::fmt::Debug,
|
||||
{
|
||||
let leptos_request = LeptosRequest::default();
|
||||
leptos_request.overwrite(Some(req));
|
||||
leptos_request
|
||||
}
|
||||
/// An Axum handlers to listens for a request with Leptos server function arguments in the body,
|
||||
/// run the server function if found, and return the resulting [Response].
|
||||
///
|
||||
@@ -302,11 +218,9 @@ async fn handle_server_fns_inner(
|
||||
|
||||
additional_context(cx);
|
||||
|
||||
let (req, req_parts) =
|
||||
generate_request_and_parts(req).await;
|
||||
let leptos_req = generate_leptos_request(req).await; // Add this so we can get details about the Request
|
||||
let req_parts = generate_request_parts(req).await;
|
||||
// Add this so we can get details about the Request
|
||||
provide_context(cx, req_parts.clone());
|
||||
provide_context(cx, leptos_req);
|
||||
// Add this so that we can set headers and status of the response
|
||||
provide_context(cx, ResponseOptions::default());
|
||||
|
||||
@@ -642,10 +556,9 @@ where
|
||||
.run_until(async {
|
||||
let app = {
|
||||
let full_path = full_path.clone();
|
||||
let (req, req_parts) = generate_request_and_parts(req).await;
|
||||
let leptos_req = generate_leptos_request(req).await;
|
||||
let req_parts = generate_request_parts(req).await;
|
||||
move |cx| {
|
||||
provide_contexts(cx, full_path, req_parts,leptos_req, default_res_options);
|
||||
provide_contexts(cx, full_path, req_parts, default_res_options);
|
||||
app_fn(cx).into_view(cx)
|
||||
}
|
||||
};
|
||||
@@ -811,10 +724,9 @@ where
|
||||
.run_until(async {
|
||||
let app = {
|
||||
let full_path = full_path.clone();
|
||||
let (req, req_parts) = generate_request_and_parts(req).await;
|
||||
let leptos_req = generate_leptos_request(req).await;
|
||||
let req_parts = generate_request_parts(req).await;
|
||||
move |cx| {
|
||||
provide_contexts(cx, full_path, req_parts,leptos_req, default_res_options);
|
||||
provide_contexts(cx, full_path, req_parts, default_res_options);
|
||||
app_fn(cx).into_view(cx)
|
||||
}
|
||||
};
|
||||
@@ -840,18 +752,16 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
fn provide_contexts<B: 'static + std::fmt::Debug + std::default::Default>(
|
||||
fn provide_contexts(
|
||||
cx: Scope,
|
||||
path: String,
|
||||
req_parts: RequestParts,
|
||||
leptos_req: LeptosRequest<B>,
|
||||
default_res_options: ResponseOptions,
|
||||
) {
|
||||
let integration = ServerIntegration { path };
|
||||
provide_context(cx, RouterIntegrationContext::new(integration));
|
||||
provide_context(cx, MetaContext::new());
|
||||
provide_context(cx, req_parts);
|
||||
provide_context(cx, leptos_req);
|
||||
provide_context(cx, default_res_options);
|
||||
provide_server_redirect(cx, move |path| redirect(cx, path));
|
||||
}
|
||||
@@ -994,16 +904,15 @@ where
|
||||
.run_until(async {
|
||||
let app = {
|
||||
let full_path = full_path.clone();
|
||||
let (req, req_parts) = generate_request_and_parts(req).await;
|
||||
let leptos_req = generate_leptos_request(req).await;
|
||||
let req_parts = generate_request_parts(req).await;
|
||||
move |cx| {
|
||||
provide_contexts(cx, full_path, req_parts,leptos_req, default_res_options);
|
||||
provide_contexts(cx, full_path, req_parts, default_res_options);
|
||||
app_fn(cx).into_view(cx)
|
||||
}
|
||||
};
|
||||
|
||||
let (stream, runtime, scope) =
|
||||
render_to_stream_in_order_with_prefix_undisposed_with_context(
|
||||
render_to_stream_with_prefix_undisposed_with_context(
|
||||
app,
|
||||
|_| "".into(),
|
||||
add_context,
|
||||
|
||||
@@ -10,7 +10,6 @@ description = "Utilities to help build server integrations for the Leptos web fr
|
||||
[dependencies]
|
||||
futures = "0.3"
|
||||
leptos = { workspace = true, features = ["ssr"] }
|
||||
leptos_hot_reload = { workspace = true }
|
||||
leptos_meta = { workspace = true, features = ["ssr"] }
|
||||
leptos_router = { workspace = true, features = ["ssr"] }
|
||||
leptos_config = { workspace = true }
|
||||
|
||||
@@ -25,7 +25,6 @@ pub fn html_parts(
|
||||
true => format!(
|
||||
r#"
|
||||
<script crossorigin="">(function () {{
|
||||
{}
|
||||
var ws = new WebSocket('ws://{site_ip}:{reload_port}/live_reload');
|
||||
ws.onmessage = (ev) => {{
|
||||
let msg = JSON.parse(ev.data);
|
||||
@@ -41,15 +40,11 @@ pub fn html_parts(
|
||||
}});
|
||||
if (!found) console.warn(`CSS hot-reload: Could not find a <link href=/\"${{msg.css}}\"> element`);
|
||||
}};
|
||||
if(msg.view) {{
|
||||
patch(msg.view);
|
||||
}}
|
||||
}};
|
||||
ws.onclose = () => console.warn('Live-reload stopped. Manual reload necessary.');
|
||||
}})()
|
||||
</script>
|
||||
"#,
|
||||
leptos_hot_reload::HOT_RELOAD_JS
|
||||
"#
|
||||
),
|
||||
false => "".to_string(),
|
||||
};
|
||||
|
||||
@@ -16,8 +16,7 @@ leptos_reactive = { workspace = true }
|
||||
leptos_server = { workspace = true }
|
||||
leptos_config = { workspace = true }
|
||||
tracing = "0.1"
|
||||
typed-builder = "0.14"
|
||||
server_fn = { workspace = true }
|
||||
typed-builder = "0.12"
|
||||
|
||||
[dev-dependencies]
|
||||
leptos = { path = ".", default-features = false }
|
||||
@@ -51,7 +50,6 @@ stable = [
|
||||
serde = ["leptos_reactive/serde"]
|
||||
serde-lite = ["leptos_reactive/serde-lite"]
|
||||
miniserde = ["leptos_reactive/miniserde"]
|
||||
rkyv = ["leptos_reactive/rkyv"]
|
||||
tracing = ["leptos_macro/tracing"]
|
||||
|
||||
[package.metadata.cargo-all-features]
|
||||
@@ -81,16 +79,4 @@ skip_feature_sets = [
|
||||
"serde",
|
||||
"miniserde",
|
||||
],
|
||||
[
|
||||
"serde",
|
||||
"rkyv",
|
||||
],
|
||||
[
|
||||
"miniserde",
|
||||
"rkyv",
|
||||
],
|
||||
[
|
||||
"serde-lite",
|
||||
"rkyv",
|
||||
],
|
||||
]
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
[tasks.check-wasm]
|
||||
[tasks.build-wasm]
|
||||
clear = true
|
||||
dependencies = ["check-hydrate", "check-csr"]
|
||||
dependencies = ["build-hydrate", "build-csr"]
|
||||
|
||||
[tasks.check-hydrate]
|
||||
[tasks.build-hydrate]
|
||||
command = "cargo"
|
||||
args = [
|
||||
"check",
|
||||
"build",
|
||||
"--no-default-features",
|
||||
"--features=hydrate",
|
||||
"--target=wasm32-unknown-unknown",
|
||||
]
|
||||
|
||||
[tasks.check-csr]
|
||||
[tasks.build-csr]
|
||||
command = "cargo"
|
||||
args = [
|
||||
"check",
|
||||
"build",
|
||||
"--no-default-features",
|
||||
"--features=csr",
|
||||
"--target=wasm32-unknown-unknown",
|
||||
|
||||
@@ -38,6 +38,11 @@
|
||||
//! `Result` types to handle errors.
|
||||
//! - [`parent_child`](https://github.com/leptos-rs/leptos/tree/main/examples/parent_child) shows four different
|
||||
//! ways a parent component can communicate with a child, including passing a closure, context, and more
|
||||
//! - [`todomvc`](https://github.com/leptos-rs/leptos/tree/main/examples/todomvc) implements the classic to-do
|
||||
//! app in Leptos. This is a good example of a complete, simple app. In particular, you might want to
|
||||
//! see how we use [create_effect] to [serialize JSON to `localStorage`](https://github.com/leptos-rs/leptos/blob/16f084a71268ac325fbc4a5e50c260df185eadb6/examples/todomvc/src/lib.rs#L164)
|
||||
//! and [reactively call DOM methods](https://github.com/leptos-rs/leptos/blob/6d7c36655c9e7dcc3a3ad33d2b846a3f00e4ae74/examples/todomvc/src/lib.rs#L291)
|
||||
//! on [references to elements](https://github.com/leptos-rs/leptos/blob/6d7c36655c9e7dcc3a3ad33d2b846a3f00e4ae74/examples/todomvc/src/lib.rs#L254).
|
||||
//! - [`fetch`](https://github.com/leptos-rs/leptos/tree/main/examples/fetch) introduces
|
||||
//! [Resource](leptos_reactive::Resource)s, which allow you to integrate arbitrary `async` code like an
|
||||
//! HTTP request within your reactive code.
|
||||
@@ -49,10 +54,6 @@
|
||||
//! - [`todomvc`](https://github.com/leptos-rs/leptos/tree/main/examples/todomvc) shows the basics of building an
|
||||
//! isomorphic web app. Both the server and the client import the same app code from the `todomvc` example.
|
||||
//! The server renders the app directly to an HTML string, and the client hydrates that HTML to make it interactive.
|
||||
//! You might also want to
|
||||
//! see how we use [create_effect] to [serialize JSON to `localStorage`](https://github.com/leptos-rs/leptos/blob/16f084a71268ac325fbc4a5e50c260df185eadb6/examples/todomvc/src/lib.rs#L164)
|
||||
//! and [reactively call DOM methods](https://github.com/leptos-rs/leptos/blob/6d7c36655c9e7dcc3a3ad33d2b846a3f00e4ae74/examples/todomvc/src/lib.rs#L291)
|
||||
//! on [references to elements](https://github.com/leptos-rs/leptos/blob/6d7c36655c9e7dcc3a3ad33d2b846a3f00e4ae74/examples/todomvc/src/lib.rs#L254).
|
||||
//! - [`hackernews`](https://github.com/leptos-rs/leptos/tree/main/examples/hackernews)
|
||||
//! and [`hackernews_axum`](https://github.com/leptos-rs/leptos/tree/main/examples/hackernews_axum)
|
||||
//! integrate calls to a real external REST API, routing, server-side rendering and hydration to create
|
||||
@@ -167,7 +168,6 @@ pub use leptos_server::{
|
||||
self, create_action, create_multi_action, create_server_action,
|
||||
create_server_multi_action, Action, MultiAction, ServerFn, ServerFnError,
|
||||
};
|
||||
pub use server_fn::{self, ServerFn as _};
|
||||
pub use typed_builder;
|
||||
mod error_boundary;
|
||||
pub use error_boundary::*;
|
||||
|
||||
@@ -119,7 +119,9 @@ where
|
||||
if is_first_run(&first_run, &suspense_context) {
|
||||
let has_local_only = suspense_context.has_local_only();
|
||||
*prev_children.borrow_mut() = Some(frag.nodes.clone());
|
||||
if !has_local_only || child_runs.get() > 0 {
|
||||
if (has_local_only && child_runs.get() > 0)
|
||||
|| !has_local_only
|
||||
{
|
||||
first_run.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,13 +16,11 @@ fn simple_ssr_test() {
|
||||
|
||||
assert_eq!(
|
||||
rendered.into_view(cx).render_to_string(cx),
|
||||
"<!--leptos-view|leptos-tests-ssr.rs-8|open--><div \
|
||||
id=\"_0-1\"><button id=\"_0-2\">-1</button><span \
|
||||
"<div id=\"_0-1\"><button id=\"_0-2\">-1</button><span \
|
||||
id=\"_0-3\">Value: \
|
||||
<!--hk=_0-4o|leptos-dyn-child-start-->0<!\
|
||||
--hk=_0-4c|leptos-dyn-child-end-->!</span><button \
|
||||
id=\"_0-5\">+1</button></div><!--leptos-view|leptos-tests-ssr.\
|
||||
rs-8|close-->"
|
||||
id=\"_0-5\">+1</button></div>"
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -56,25 +54,21 @@ fn ssr_test_with_components() {
|
||||
|
||||
assert_eq!(
|
||||
rendered.into_view(cx).render_to_string(cx),
|
||||
"<!--leptos-view|leptos-tests-ssr.rs-49|open--><div id=\"_0-1\" \
|
||||
class=\"counters\"><!--hk=_0-1-0o|leptos-counter-start--><!\
|
||||
--leptos-view|leptos-tests-ssr.rs-38|open--><div \
|
||||
"<div class=\"counters\" \
|
||||
id=\"_0-1\"><!--hk=_0-1-0o|leptos-counter-start--><div \
|
||||
id=\"_0-1-1\"><button id=\"_0-1-2\">-1</button><span \
|
||||
id=\"_0-1-3\">Value: \
|
||||
<!--hk=_0-1-4o|leptos-dyn-child-start-->1<!\
|
||||
--hk=_0-1-4c|leptos-dyn-child-end-->!</span><button \
|
||||
id=\"_0-1-5\">+1</button></div><!--leptos-view|leptos-tests-ssr.\
|
||||
rs-38|close--><!--hk=_0-1-0c|leptos-counter-end--><!\
|
||||
--hk=_0-1-5-0o|leptos-counter-start--><!\
|
||||
--leptos-view|leptos-tests-ssr.rs-38|open--><div \
|
||||
id=\"_0-1-5\">+1</button></div><!\
|
||||
--hk=_0-1-0c|leptos-counter-end--><!\
|
||||
--hk=_0-1-5-0o|leptos-counter-start--><div \
|
||||
id=\"_0-1-5-1\"><button id=\"_0-1-5-2\">-1</button><span \
|
||||
id=\"_0-1-5-3\">Value: \
|
||||
<!--hk=_0-1-5-4o|leptos-dyn-child-start-->2<!\
|
||||
--hk=_0-1-5-4c|leptos-dyn-child-end-->!</span><button \
|
||||
id=\"_0-1-5-5\">+1</button></div><!\
|
||||
--leptos-view|leptos-tests-ssr.rs-38|close--><!\
|
||||
--hk=_0-1-5-0c|leptos-counter-end--></div><!\
|
||||
--leptos-view|leptos-tests-ssr.rs-49|close-->"
|
||||
--hk=_0-1-5-0c|leptos-counter-end--></div>"
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -108,26 +102,22 @@ fn ssr_test_with_snake_case_components() {
|
||||
|
||||
assert_eq!(
|
||||
rendered.into_view(cx).render_to_string(cx),
|
||||
"<!--leptos-view|leptos-tests-ssr.rs-101|open--><div id=\"_0-1\" \
|
||||
class=\"counters\"><!\
|
||||
--hk=_0-1-0o|leptos-snake-case-counter-start--><!\
|
||||
--leptos-view|leptos-tests-ssr.rs-90|open--><div \
|
||||
"<div class=\"counters\" \
|
||||
id=\"_0-1\"><!\
|
||||
--hk=_0-1-0o|leptos-snake-case-counter-start--><div \
|
||||
id=\"_0-1-1\"><button id=\"_0-1-2\">-1</button><span \
|
||||
id=\"_0-1-3\">Value: \
|
||||
<!--hk=_0-1-4o|leptos-dyn-child-start-->1<!\
|
||||
--hk=_0-1-4c|leptos-dyn-child-end-->!</span><button \
|
||||
id=\"_0-1-5\">+1</button></div><!--leptos-view|leptos-tests-ssr.\
|
||||
rs-90|close--><!--hk=_0-1-0c|leptos-snake-case-counter-end--><!\
|
||||
--hk=_0-1-5-0o|leptos-snake-case-counter-start--><!\
|
||||
--leptos-view|leptos-tests-ssr.rs-90|open--><div \
|
||||
id=\"_0-1-5\">+1</button></div><!\
|
||||
--hk=_0-1-0c|leptos-snake-case-counter-end--><!\
|
||||
--hk=_0-1-5-0o|leptos-snake-case-counter-start--><div \
|
||||
id=\"_0-1-5-1\"><button id=\"_0-1-5-2\">-1</button><span \
|
||||
id=\"_0-1-5-3\">Value: \
|
||||
<!--hk=_0-1-5-4o|leptos-dyn-child-start-->2<!\
|
||||
--hk=_0-1-5-4c|leptos-dyn-child-end-->!</span><button \
|
||||
id=\"_0-1-5-5\">+1</button></div><!\
|
||||
--leptos-view|leptos-tests-ssr.rs-90|close--><!\
|
||||
--hk=_0-1-5-0c|leptos-snake-case-counter-end--></div><!\
|
||||
--leptos-view|leptos-tests-ssr.rs-101|close-->"
|
||||
--hk=_0-1-5-0c|leptos-snake-case-counter-end--></div>"
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -146,9 +136,7 @@ fn test_classes() {
|
||||
|
||||
assert_eq!(
|
||||
rendered.into_view(cx).render_to_string(cx),
|
||||
"<!--leptos-view|leptos-tests-ssr.rs-142|open--><div id=\"_0-1\" \
|
||||
class=\"my big red \
|
||||
car\"></div><!--leptos-view|leptos-tests-ssr.rs-142|close-->"
|
||||
"<div class=\"my big red car\" id=\"_0-1\"></div>"
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -170,10 +158,8 @@ fn ssr_with_styles() {
|
||||
|
||||
assert_eq!(
|
||||
rendered.into_view(cx).render_to_string(cx),
|
||||
"<!--leptos-view|leptos-tests-ssr.rs-164|open--><div id=\"_0-1\" \
|
||||
class=\" myclass\"><button id=\"_0-2\" class=\"btn \
|
||||
myclass\">-1</button></div><!--leptos-view|leptos-tests-ssr.\
|
||||
rs-164|close-->"
|
||||
"<div class=\"myclass\" id=\"_0-1\"><button class=\"btn myclass\" \
|
||||
id=\"_0-2\">-1</button></div>"
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -192,9 +178,7 @@ fn ssr_option() {
|
||||
|
||||
assert_eq!(
|
||||
rendered.into_view(cx).render_to_string(cx),
|
||||
"<!--leptos-view|leptos-tests-ssr.rs-188|open--><option \
|
||||
id=\"_0-1\"></option><!--leptos-view|leptos-tests-ssr.\
|
||||
rs-188|close-->"
|
||||
"<option id=\"_0-1\"></option>"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -14,8 +14,4 @@ fs = "0.0.5"
|
||||
regex = "1.7.0"
|
||||
serde = { version = "1.0.151", features = ["derive"] }
|
||||
thiserror = "1.0.38"
|
||||
typed-builder = "0.14"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { version = "1", features = ["rt", "macros"] }
|
||||
tempfile = "3"
|
||||
typed-builder = "0.12"
|
||||
|
||||
@@ -5,10 +5,7 @@ pub mod errors;
|
||||
use crate::errors::LeptosConfigError;
|
||||
use config::{Config, File, FileFormat};
|
||||
use regex::Regex;
|
||||
use std::{
|
||||
convert::TryFrom, env::VarError, fs, net::SocketAddr, path::Path,
|
||||
str::FromStr,
|
||||
};
|
||||
use std::{convert::TryFrom, env::VarError, fs, net::SocketAddr, str::FromStr};
|
||||
use typed_builder::TypedBuilder;
|
||||
|
||||
/// A Struct to allow us to parse LeptosOptions from the file. Not really needed, most interactions should
|
||||
@@ -120,7 +117,6 @@ impl From<&str> for Env {
|
||||
from_str(str).unwrap_or_else(|err| panic!("{}", err))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&Result<String, VarError>> for Env {
|
||||
fn from(input: &Result<String, VarError>) -> Self {
|
||||
match input {
|
||||
@@ -138,67 +134,48 @@ impl TryFrom<String> for Env {
|
||||
}
|
||||
}
|
||||
|
||||
/// Loads [LeptosOptions] from a Cargo.toml text content with layered overrides.
|
||||
/// If an env var is specified, like `LEPTOS_ENV`, it will override a setting in the file.
|
||||
pub fn get_config_from_str(text: &str) -> Result<ConfFile, LeptosConfigError> {
|
||||
let re: Regex = Regex::new(r#"(?m)^\[package.metadata.leptos\]"#).unwrap();
|
||||
let start = match re.find(text) {
|
||||
Some(found) => found.start(),
|
||||
None => return Err(LeptosConfigError::ConfigSectionNotFound),
|
||||
};
|
||||
|
||||
// so that serde error messages have right line number
|
||||
let newlines = text[..start].matches('\n').count();
|
||||
let input = "\n".repeat(newlines) + &text[start..];
|
||||
let toml = input
|
||||
.replace("[package.metadata.leptos]", "[leptos_options]")
|
||||
.replace('-', "_");
|
||||
let settings = Config::builder()
|
||||
// Read the "default" configuration file
|
||||
.add_source(File::from_str(&toml, FileFormat::Toml))
|
||||
// Layer on the environment-specific values.
|
||||
// Add in settings from environment variables (with a prefix of LEPTOS and '_' as separator)
|
||||
// E.g. `LEPTOS_RELOAD_PORT=5001 would set `LeptosOptions.reload_port`
|
||||
.add_source(config::Environment::with_prefix("LEPTOS").separator("_"))
|
||||
.build()?;
|
||||
|
||||
settings
|
||||
.try_deserialize()
|
||||
.map_err(|e| LeptosConfigError::ConfigError(e.to_string()))
|
||||
}
|
||||
|
||||
/// Loads [LeptosOptions] from a Cargo.toml with layered overrides. If an env var is specified, like `LEPTOS_ENV`,
|
||||
/// it will override a setting in the file. It takes in an optional path to a Cargo.toml file. If None is provided,
|
||||
/// you'll need to set the options as environment variables or rely on the defaults. This is the preferred
|
||||
/// approach for cargo-leptos. If Some("./Cargo.toml") is provided, Leptos will read in the settings itself. This
|
||||
/// option currently does not allow dashes in file or folder names, as all dashes become underscores
|
||||
/// option currently does not allow dashes in file or foldernames, as all dashes become underscores
|
||||
pub async fn get_configuration(
|
||||
path: Option<&str>,
|
||||
) -> Result<ConfFile, LeptosConfigError> {
|
||||
if let Some(path) = path {
|
||||
get_config_from_file(&path).await
|
||||
let text = fs::read_to_string(path)
|
||||
.map_err(|_| LeptosConfigError::ConfigNotFound)?;
|
||||
|
||||
let re: Regex =
|
||||
Regex::new(r#"(?m)^\[package.metadata.leptos\]"#).unwrap();
|
||||
let start = match re.find(&text) {
|
||||
Some(found) => found.start(),
|
||||
None => return Err(LeptosConfigError::ConfigSectionNotFound),
|
||||
};
|
||||
|
||||
// so that serde error messages have right line number
|
||||
let newlines = text[..start].matches('\n').count();
|
||||
let input = "\n".repeat(newlines) + &text[start..];
|
||||
let toml = input
|
||||
.replace("[package.metadata.leptos]", "[leptos_options]")
|
||||
.replace('-', "_");
|
||||
let settings = Config::builder()
|
||||
// Read the "default" configuration file
|
||||
.add_source(File::from_str(&toml, FileFormat::Toml))
|
||||
// Layer on the environment-specific values.
|
||||
// Add in settings from environment variables (with a prefix of LEPTOS and '_' as separator)
|
||||
// E.g. `LEPTOS_RELOAD_PORT=5001 would set `LeptosOptions.reload_port`
|
||||
.add_source(
|
||||
config::Environment::with_prefix("LEPTOS").separator("_"),
|
||||
)
|
||||
.build()?;
|
||||
|
||||
settings
|
||||
.try_deserialize()
|
||||
.map_err(|e| LeptosConfigError::ConfigError(e.to_string()))
|
||||
} else {
|
||||
get_config_from_env()
|
||||
Ok(ConfFile {
|
||||
leptos_options: LeptosOptions::try_from_env()?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Loads [LeptosOptions] from a Cargo.toml with layered overrides. Leptos will read in the settings itself. This
|
||||
/// option currently does not allow dashes in file or folder names, as all dashes become underscores
|
||||
pub async fn get_config_from_file<P: AsRef<Path>>(
|
||||
path: P,
|
||||
) -> Result<ConfFile, LeptosConfigError> {
|
||||
let text = fs::read_to_string(path)
|
||||
.map_err(|_| LeptosConfigError::ConfigNotFound)?;
|
||||
get_config_from_str(&text)
|
||||
}
|
||||
|
||||
/// Loads [LeptosOptions] from environment variables or rely on the defaults
|
||||
pub fn get_config_from_env() -> Result<ConfFile, LeptosConfigError> {
|
||||
Ok(ConfFile {
|
||||
leptos_options: LeptosOptions::try_from_env()?,
|
||||
})
|
||||
}
|
||||
|
||||
#[path = "tests.rs"]
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
use crate::{env_w_default, from_str, Env, LeptosOptions};
|
||||
use std::{net::SocketAddr, str::FromStr};
|
||||
|
||||
#[test]
|
||||
fn from_str_env() {
|
||||
assert!(matches!(from_str("dev").unwrap(), Env::DEV));
|
||||
assert!(matches!(from_str("development").unwrap(), Env::DEV));
|
||||
assert!(matches!(from_str("DEV").unwrap(), Env::DEV));
|
||||
assert!(matches!(from_str("DEVELOPMENT").unwrap(), Env::DEV));
|
||||
assert!(matches!(from_str("prod").unwrap(), Env::PROD));
|
||||
assert!(matches!(from_str("production").unwrap(), Env::PROD));
|
||||
assert!(matches!(from_str("PROD").unwrap(), Env::PROD));
|
||||
assert!(matches!(from_str("PRODUCTION").unwrap(), Env::PROD));
|
||||
assert!(from_str("TEST").is_err());
|
||||
assert!(from_str("?").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_w_default_test() {
|
||||
std::env::set_var("LEPTOS_CONFIG_ENV_TEST", "custom");
|
||||
assert_eq!(
|
||||
env_w_default("LEPTOS_CONFIG_ENV_TEST", "default").unwrap(),
|
||||
String::from("custom")
|
||||
);
|
||||
std::env::remove_var("LEPTOS_CONFIG_ENV_TEST");
|
||||
assert_eq!(
|
||||
env_w_default("LEPTOS_CONFIG_ENV_TEST", "default").unwrap(),
|
||||
String::from("default")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_from_env_test() {
|
||||
std::env::remove_var("LEPTOS_OUTPUT_NAME");
|
||||
assert!(LeptosOptions::try_from_env().is_err());
|
||||
|
||||
// Test config values from environment variables
|
||||
std::env::set_var("LEPTOS_OUTPUT_NAME", "app_test");
|
||||
std::env::set_var("LEPTOS_SITE_ROOT", "my_target/site");
|
||||
std::env::set_var("LEPTOS_SITE_PKG_DIR", "my_pkg");
|
||||
std::env::set_var("LEPTOS_SITE_ADDR", "0.0.0.0:80");
|
||||
std::env::set_var("LEPTOS_RELOAD_PORT", "8080");
|
||||
|
||||
let config = LeptosOptions::try_from_env().unwrap();
|
||||
assert_eq!(config.output_name, "app_test");
|
||||
|
||||
assert_eq!(config.site_root, "my_target/site");
|
||||
assert_eq!(config.site_pkg_dir, "my_pkg");
|
||||
assert_eq!(
|
||||
config.site_addr,
|
||||
SocketAddr::from_str("0.0.0.0:80").unwrap()
|
||||
);
|
||||
assert_eq!(config.reload_port, 8080);
|
||||
|
||||
// Test default config values
|
||||
std::env::remove_var("LEPTOS_SITE_ROOT");
|
||||
std::env::remove_var("LEPTOS_SITE_PKG_DIR");
|
||||
std::env::remove_var("LEPTOS_SITE_ADDR");
|
||||
std::env::remove_var("LEPTOS_RELOAD_PORT");
|
||||
|
||||
let config = LeptosOptions::try_from_env().unwrap();
|
||||
assert_eq!(config.site_root, "target/site");
|
||||
assert_eq!(config.site_pkg_dir, "pkg");
|
||||
assert_eq!(
|
||||
config.site_addr,
|
||||
SocketAddr::from_str("127.0.0.1:3000").unwrap()
|
||||
);
|
||||
assert_eq!(config.reload_port, 3001);
|
||||
}
|
||||
@@ -1,192 +0,0 @@
|
||||
use leptos_config::{
|
||||
get_config_from_file, get_config_from_str, get_configuration, Env,
|
||||
LeptosOptions,
|
||||
};
|
||||
use std::{fs::File, io::Write, net::SocketAddr, path::Path, str::FromStr};
|
||||
use tempfile::NamedTempFile;
|
||||
|
||||
#[test]
|
||||
fn env_default() {
|
||||
assert!(matches!(Env::default(), Env::DEV));
|
||||
}
|
||||
|
||||
const CARGO_TOML_CONTENT_OK: &str = r#"\
|
||||
[package.metadata.leptos]
|
||||
output-name = "app-test"
|
||||
site-root = "my_target/site"
|
||||
site-pkg-dir = "my_pkg"
|
||||
site-addr = "0.0.0.0:80"
|
||||
reload-port = "8080"
|
||||
env = "PROD"
|
||||
"#;
|
||||
|
||||
const CARGO_TOML_CONTENT_ERR: &str = r#"\
|
||||
[package.metadata.leptos]
|
||||
_output-name = "app-test"
|
||||
_site-root = "my_target/site"
|
||||
_site-pkg-dir = "my_pkg"
|
||||
_site-addr = "0.0.0.0:80"
|
||||
_reload-port = "8080"
|
||||
_env = "PROD"
|
||||
"#;
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_configuration_from_file_ok() {
|
||||
let cargo_tmp = NamedTempFile::new().unwrap();
|
||||
{
|
||||
let mut output = File::create(&cargo_tmp).unwrap();
|
||||
write!(output, "{CARGO_TOML_CONTENT_OK}").unwrap();
|
||||
}
|
||||
|
||||
let path: &Path = cargo_tmp.as_ref();
|
||||
let path_s = path.to_string_lossy().to_string();
|
||||
|
||||
let config = get_configuration(Some(&path_s))
|
||||
.await
|
||||
.unwrap()
|
||||
.leptos_options;
|
||||
|
||||
assert_eq!(config.output_name, "app_test");
|
||||
assert_eq!(config.site_root, "my_target/site");
|
||||
assert_eq!(config.site_pkg_dir, "my_pkg");
|
||||
assert_eq!(
|
||||
config.site_addr,
|
||||
SocketAddr::from_str("0.0.0.0:80").unwrap()
|
||||
);
|
||||
assert_eq!(config.reload_port, 8080);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_configuration_from_invalid_file() {
|
||||
let cargo_tmp = NamedTempFile::new().unwrap();
|
||||
{
|
||||
let mut output = File::create(&cargo_tmp).unwrap();
|
||||
write!(output, "{CARGO_TOML_CONTENT_ERR}").unwrap();
|
||||
}
|
||||
let path: &Path = cargo_tmp.as_ref();
|
||||
let path_s = path.to_string_lossy().to_string();
|
||||
assert!(get_configuration(Some(&path_s)).await.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_configuration_from_empty_file() {
|
||||
let cargo_tmp = NamedTempFile::new().unwrap();
|
||||
{
|
||||
let mut output = File::create(&cargo_tmp).unwrap();
|
||||
write!(output, "").unwrap();
|
||||
}
|
||||
let path: &Path = cargo_tmp.as_ref();
|
||||
let path_s = path.to_string_lossy().to_string();
|
||||
assert!(get_configuration(Some(&path_s)).await.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_config_from_file_ok() {
|
||||
let cargo_tmp = NamedTempFile::new().unwrap();
|
||||
{
|
||||
let mut output = File::create(&cargo_tmp).unwrap();
|
||||
write!(output, "{CARGO_TOML_CONTENT_OK}").unwrap();
|
||||
}
|
||||
|
||||
let config = get_config_from_file(&cargo_tmp)
|
||||
.await
|
||||
.unwrap()
|
||||
.leptos_options;
|
||||
|
||||
assert_eq!(config.output_name, "app_test");
|
||||
assert_eq!(config.site_root, "my_target/site");
|
||||
assert_eq!(config.site_pkg_dir, "my_pkg");
|
||||
assert_eq!(
|
||||
config.site_addr,
|
||||
SocketAddr::from_str("0.0.0.0:80").unwrap()
|
||||
);
|
||||
assert_eq!(config.reload_port, 8080);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_config_from_file_invalid() {
|
||||
let cargo_tmp = NamedTempFile::new().unwrap();
|
||||
{
|
||||
let mut output = File::create(&cargo_tmp).unwrap();
|
||||
write!(output, "{CARGO_TOML_CONTENT_ERR}").unwrap();
|
||||
}
|
||||
assert!(get_config_from_file(&cargo_tmp).await.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_config_from_file_empty() {
|
||||
let cargo_tmp = NamedTempFile::new().unwrap();
|
||||
{
|
||||
let mut output = File::create(&cargo_tmp).unwrap();
|
||||
write!(output, "").unwrap();
|
||||
}
|
||||
assert!(get_config_from_file(&cargo_tmp).await.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_config_from_str_content() {
|
||||
let config = get_config_from_str(CARGO_TOML_CONTENT_OK)
|
||||
.unwrap()
|
||||
.leptos_options;
|
||||
assert_eq!(config.output_name, "app_test");
|
||||
assert_eq!(config.site_root, "my_target/site");
|
||||
assert_eq!(config.site_pkg_dir, "my_pkg");
|
||||
assert_eq!(
|
||||
config.site_addr,
|
||||
SocketAddr::from_str("0.0.0.0:80").unwrap()
|
||||
);
|
||||
assert_eq!(config.reload_port, 8080);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_config_from_env() {
|
||||
std::env::remove_var("LEPTOS_OUTPUT_NAME");
|
||||
assert!(get_configuration(None).await.is_err());
|
||||
|
||||
// Test config values from environment variables
|
||||
std::env::set_var("LEPTOS_OUTPUT_NAME", "app_test");
|
||||
std::env::set_var("LEPTOS_SITE_ROOT", "my_target/site");
|
||||
std::env::set_var("LEPTOS_SITE_PKG_DIR", "my_pkg");
|
||||
std::env::set_var("LEPTOS_SITE_ADDR", "0.0.0.0:80");
|
||||
std::env::set_var("LEPTOS_RELOAD_PORT", "8080");
|
||||
|
||||
let config = get_configuration(None).await.unwrap().leptos_options;
|
||||
assert_eq!(config.output_name, "app_test");
|
||||
|
||||
assert_eq!(config.site_root, "my_target/site");
|
||||
assert_eq!(config.site_pkg_dir, "my_pkg");
|
||||
assert_eq!(
|
||||
config.site_addr,
|
||||
SocketAddr::from_str("0.0.0.0:80").unwrap()
|
||||
);
|
||||
assert_eq!(config.reload_port, 8080);
|
||||
|
||||
// Test default config values
|
||||
std::env::remove_var("LEPTOS_SITE_ROOT");
|
||||
std::env::remove_var("LEPTOS_SITE_PKG_DIR");
|
||||
std::env::remove_var("LEPTOS_SITE_ADDR");
|
||||
std::env::remove_var("LEPTOS_RELOAD_PORT");
|
||||
|
||||
let config = get_configuration(None).await.unwrap().leptos_options;
|
||||
assert_eq!(config.site_root, "target/site");
|
||||
assert_eq!(config.site_pkg_dir, "pkg");
|
||||
assert_eq!(
|
||||
config.site_addr,
|
||||
SocketAddr::from_str("127.0.0.1:3000").unwrap()
|
||||
);
|
||||
assert_eq!(config.reload_port, 3001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leptos_options_builder_default() {
|
||||
let conf = LeptosOptions::builder().output_name("app_test").build();
|
||||
assert_eq!(conf.output_name, "app_test");
|
||||
assert!(matches!(conf.env, Env::DEV));
|
||||
assert_eq!(conf.site_pkg_dir, "pkg");
|
||||
assert_eq!(conf.site_root, ".");
|
||||
assert_eq!(
|
||||
conf.site_addr,
|
||||
SocketAddr::from_str("127.0.0.1:3000").unwrap()
|
||||
);
|
||||
assert_eq!(conf.reload_port, 3001);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user