Simple CRUD app with a database and partial rendering. Every rust crate starts with the toml
manifest:
Cargo.toml
[package]
name = "todo"
edition = "2021"
[dependencies]
prest = { path = "../../", version = "0.4" }
It contains core metadata of the package that cargo needs to build it properly. With this tiny config we can move on to writing 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, Serialize, Deserialize)]
struct Todo {
#[serde(default = "Uuid::now_v7")]
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 justify-between items-center" swap-this vals=(json!(self)) {
input type="checkbox" patch="/" checked[self.done] {}
label $"ml-4 text-lg" {(self.task)}
button $"ml-auto" delete="/" {"Delete"}
}
}
}
}
...
Since this is a quite unusal templating solution and used together with HTMX and built-in Tailwind classes 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
$"..."
works somewhat like class html attribute in tailwind buthtml!
macro converts them into embedded cssswap-this
(alias forhx-target="this" hx-swap="outerHTML"
) configures htmx to swap this element and to swap it whole (by default it swaps it's children =innerHTML
)vals=(json!(self))
(alias forhx-vals
) adds json-serialized todo fields which htmx will form-encode and send with requestspatch="/"
(alias forhx-patch
) sets this element to sendPATCH
method requests to/
when triggereddelete="/"
(alias forhx-delete
) sets this element to sendDELETE
method requests to/
when triggeredchecked[self.done]
addschecked
attribute ifself.done
is true
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 {(Head::with_title("Todo app"))
body $"max-w-screen-sm px-8 mx-auto mt-12 flex flex-col items-center" {
form put="/" into="#list" place-end after-request="this.reset()" {
input $"border rounded-md" type="text" name="task" {}
button $"ml-4" type="submit" {"Add"}
}
div #list $"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 after-request="this.reset()"
(alias for hx-on--after-request
) 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() {
init!(tables Todo);
route(
"/",
get(|| async { ok(Todo::find_all()?.render()) })
.put(|todo: Vals<Todo>| async move { ok(todo.save()?.render()) })
.delete(|todo: Vals<Todo>| async move { ok(todo.remove()?) })
.patch(|Vals(mut todo): Vals<Todo>| async move {
ok(todo.update_done(!todo.done)?.render())
}),
)
.wrap_non_htmx(into_page)
.run();
}
The first lines invokes the init
macro with the only table we defined so it will setup basic prest configs and make sure there is a table in the database prepared for our todos.
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
, initializes the database 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, Serialize, Deserialize)]
struct Todo {
#[serde(default = "Uuid::now_v7")]
pub id: Uuid,
pub task: String,
#[serde(default)]
pub done: bool,
}
impl Render for Todo {
fn render(&self) -> Markup {
html! {
$"flex justify-between items-center" swap-this vals=(json!(self)) {
input type="checkbox" patch="/" checked[self.done] {}
label $"ml-4 text-lg" {(self.task)}
button $"ml-auto" delete="/" {"Delete"}
}
}
}
}
async fn into_page(content: Markup) -> Markup {
html! {(DOCTYPE) html {(Head::with_title("Todo app"))
body $"max-w-screen-sm px-8 mx-auto mt-12 flex flex-col items-center" {
form put="/" into="#list" place-end after-request="this.reset()" {
input $"border rounded-md" type="text" name="task" {}
button $"ml-4" type="submit" {"Add"}
}
div #list $"w-full" {(content)}
(Scripts::default())
}
}}
}
fn main() {
init!(tables Todo);
route(
"/",
get(|| async { ok(Todo::find_all()?.render()) })
.put(|todo: Vals<Todo>| async move { ok(todo.save()?.render()) })
.delete(|todo: Vals<Todo>| async move { ok(todo.remove()?) })
.patch(|Vals(mut todo): Vals<Todo>| async move {
ok(todo.update_done(!todo.done)?.render())
}),
)
.wrap_non_htmx(into_page)
.run();
}