Simple CRUD app with a database, partial rendering and decent styles. Like any rust crate it all starts with the manifest:
Cargo.toml
[package]
name = "todo"
edition = "2021"
[dependencies]
prest = "0.2"
serde = { version = "1", features = ["derive"] }
Besides prest you might notice another dependency - serde
. It is the most popular serialization and deserialization library in the rust ecosystem and the most widely supported. Its used in almost all examples and I want to find a way to re-export derive
macros without forcing users to use additional attributes, but for now they are imported from serde directly.
Now it's time for some rust. As of now prest is designed to be imported in bulk:
src/main.rs:1
use prest::*;
...
This might not be the idiomatic rust way, but it's more beginner friendly in my view. Then we define the Todo
struct and deriving some traits that we'll use later:
src/main.rs:3-10
...
#[derive(Table, Default, serde::Serialize, serde::Deserialize)]
struct Todo {
#[serde(default = "Uuid::new_v4")]
pub id: Uuid,
pub task: String,
#[serde(default)]
pub done: bool,
}
...
Here we have serialization traits and defaults to be able to deserialize a todo value from just the task
field. Also, there is a Table
derive macro that implements a trait with the same name and a couple of helper methods which will allow us to easily work with a gluesql Todos
(struct name + s
) table.
Then there is a manual implementation of the Render
trait which will allow us to use todo values directly in the markup:
src/main.rs:12-22
...
impl Render for Todo {
fn render(&self) -> Markup {
html! {
."flex items-center" hx-target="this" hx-swap="outerHTML" hx-vals=(json!(self)) {
input."toggle toggle-primary" type="checkbox" hx-patch="/" checked[self.done] {}
label."ml-4 text-lg" {(self.task)}
button."btn btn-ghost ml-auto" hx-delete="/" {"Delete"}
}
}
}
}
...
Since this is a quite unusal templating solution and used together with HTMX, Tailwind and DaisyUI let's go through it a bit:
- if the tag is not specified, like the parent tag of a todo, then it's rendered as a
div
."..."
is a shorthand for theclass
html attribute which contains tailwind/daisyui styleshx-target="this"
sets htmx to swap this element on requests from it or it's childrenhx-swap="outerHTML"
sets htmx to swap the whole element (by default it swaps it's children)hx-vals=(json!(self))
adds json-serialized todo fields which htmx will form-encode and send with requestshx-patch="/"
sets this element to sendPATCH
method requests to/
when triggeredchecked[self.done]
addschecked
attribute ifself.done
is truehx-delete="/"
sets this element to sendDELETE
method requests to/
when triggered
Then goes the function that renders the whole page based on provided content
:
src/main.rs:24-35
...
async fn into_page(content: Markup) -> Markup {
html! {(DOCTYPE) html data-theme="dark" {(Head::with_title("Todo app"))
body."max-w-screen-sm mx-auto mt-12 flex flex-col items-center" {
form hx-put="/" hx-target="div" hx-swap="beforeend" hx-on--after-request="this.reset()" {
input."input input-bordered input-primary" type="text" name="task" {}
button."btn btn-outline btn-primary ml-4" type="submit" {"Add"}
}
."w-full" {(content)}
(Scripts::default())
}
}}
}
...
It includes a couple of utility structs Head
and Scripts
which render into the head
and a bunch of script
tags respectfully. They are not essential but provide a shorthand for meaningful defaults. Other things there are common html, htmx attributes that I've mentioned above and a hx-on--after-request="this.reset()"
attribute that just invokes a little js snippet after an htmx request that clears out the form.
Alright, now we have all the components for our service so let's move on to the main
function where it all will come together:
src/main.rs:37-52
...
fn main() {
Todo::migrate();
route(
"/",
get(|| async { html!(@for todo in Todo::find_all() {(todo)}) })
.put(|Form(todo): Form<Todo>| async move { todo.save().unwrap().render() })
.patch(|Form(mut todo): Form<Todo>| async move {
todo.update_done(!todo.done).unwrap().render()
})
.delete(|Form(todo): Form<Todo>| async move {
todo.remove().unwrap();
}),
)
.wrap_non_htmx(into_page)
.run();
}
The first lines invokes the derived migrate
function of the Table
trait which will create the Todos
table with columns matching struct fields if it doesn't exist yet.
Then goes the definition of the router from just a single route /
but with closure-based handlers for 4 methods: get
, put
, patch
and delete
.
- on
GET
it retrieves all the todos from the database using the derived method and renders the whole list - on
PUT
it deserializes the todo value from the form-encoded data, saves it in the DB and renders - on
PATCH
it deserializes the todo value, toggles thedone
attribute in it and in the DB, and renders - on
DELETE
it deserializes the todo value, removes it from the DB and returns an empty body
Then goes the wrap_non_htmx
middleware that wraps response bodies with the provided function for requests that weren't initiated by htmx. As the result if you'll go the /
in browser it will initiate a usual GET
request that will render the todos and wrap them into the page markup. However, when either the form that submit's new todos, todo checkbox or the delete button will trigger an htmx request the responses won't be wrapped and would be swapped straight into the opened page.
Finally comes the .run()
utility method provided by prest that: attempts to read variables from .env
in this or parent folders, starts tracing, adds middleware to catch panics, livereload in debug or compression and request body limits in release configs, checks the PORT
env variable or defaults to 80
, checks the DB_PATH
variable and initializes storage files there or defaults to in-memory storage, and finally starts the multi-threaded concurrent web server which will process the requests.
Hurray, now the app is running and you can play around with it. It's already a dynamic full-stack app which can handle a bunch of use cases, but one of the core premises of prest is to provide installable native-like UX so that's what we'll add in the next example.
The full code of the current example for copy-pasting and reading convenience:
src/main.rs
use prest::*;
#[derive(Table, Default, serde::Serialize, serde::Deserialize)]
struct Todo {
#[serde(default = "Uuid::new_v4")]
pub id: Uuid,
pub task: String,
#[serde(default)]
pub done: bool,
}
impl Render for Todo {
fn render(&self) -> Markup {
html! {
."flex items-center" hx-target="this" hx-swap="outerHTML" hx-vals=(json!(self)) {
input."toggle toggle-primary" type="checkbox" hx-patch="/" checked[self.done] {}
label."ml-4 text-lg" {(self.task)}
button."btn btn-ghost ml-auto" hx-delete="/" {"Delete"}
}
}
}
}
async fn into_page(content: Markup) -> Markup {
html! {(DOCTYPE) html data-theme="dark" {(Head::with_title("Todo app"))
body."max-w-screen-sm mx-auto mt-12 flex flex-col items-center" {
form hx-put="/" hx-target="div" hx-swap="beforeend" hx-on--after-request="this.reset()" {
input."input input-bordered input-primary" type="text" name="task" {}
button."btn btn-outline btn-primary ml-4" type="submit" {"Add"}
}
."w-full" {(content)}
(Scripts::default())
}
}}
}
fn main() {
Todo::migrate();
route(
"/",
get(|| async { html!(@for todo in Todo::find_all() {(todo)}) })
.put(|Form(todo): Form<Todo>| async move { todo.save().unwrap().render() })
.patch(|Form(mut todo): Form<Todo>| async move {
todo.update_done(!todo.done).unwrap().render()
})
.delete(|Form(todo): Form<Todo>| async move {
todo.remove().unwrap();
}),
)
.wrap_non_htmx(into_page)
.run();
}