Quokka Principles
Quokka tries to be an ergonomic framework for quickly writing and prototyping web applications. While still being heavily worked on the final goal is to have a framework that feels like one of the good old frameworks that give you all that you need. A backend for viewing the state of your application, managing your entities, settings and authentication.
And all of this should come in a simple to configure package.
Part of that is to build up an API to make allow multiple, independent modules to move along each other. They need to be able to extend and interact with each other.
Quokka tries to archive this exposing (sub-)states which allow modules to extend parts of the applications like templates, styling and more.
With the states Quokka encourages you to build your application or library more object oriented (in the sense of OOP). More or less all interactions in Quokka require a struct which implements a trait. But also again these structs are usually build from the Quokka state giving you some kind of dependency injection wherever you use structs.
This book will introduce you to the builtin (sub-)states and shows you how to further extend the framework with your own bundles and how to let other bundles extend your bundle too. There is of cause no right or wrong, but all that this book contains is my opinion/recommendadion on how to structure and build your bundles so that others can navigate through your code and know how to look for what.
Quokka is very open about adopting other opinions too! Quokka is opinionated. But this opinion should not only come from me. I want to have a community around it which shapes how Quokka application are build. So if you want to participate in the development and express your opinion feel to open an issue on GitLab and put the "Discussion" label on it.
Directory Structure & Files
As said, nothing here is a requirement, only a recommendation.
This is an example structure that is used in the "quokka-scratch" template and all my other modules.
| Path | Job |
|---|---|
/src/entity | This directory contains your entities and entity repositories, which communicate directly with the database. |
/src/service | Here is the home of the services. They are supposed to do the heavy lifting and your business logic. |
/src/service/page_loader | Whenever you want to provide data to your template, place a TemplateDataLoader in this directory. The TemplateDataLoaders are supposed as a thin wrapper between your template and services which refine the data of the services even more to make them usable in templates. This directory is somewhat optional, for smaller libraries it can be fine to have the loaders and stores in the service directory too. |
/src/command | If you need commands, this is the place to go. Put a struct here and implement the CommandHandler. Just don't forget to register your command in your bundle, so that it can actually be used. |
/src/controller | Every web application needs controllers! Most of them can probably be implemented using the template helpers like the StaticTemplate, DataTemplate or FormTemplate. |
/web/assets/$MODULE_NAME | Contains the static assets of the bundle. I'd like to recommand to provide a subdirectory named like your bundle to prevent collisions. The resources can be retrieved from /resources/$MODULE_NAME/$PATH. |
/web/templates | Contains the HTML templates, styles and scripts. Also here, providing a subdirectory, is recommended. I also personally like to keep all the resources (script, styles & templates) in the same "component" directory to have everything in a single place. But you might also very well create separate directories for each set of files. |
/web/templates/$MODULE | The template directory will get a subdirectory which is the name of you module. By this you can avoid to have template conflicts. Also others can target to overwrite your templates. |
/web/templates/$MODULE/style.scss | This style file should @use all your style files. The compiled file can then be loaded from the /styling/$MODULE/style.scss url. |
/web/templates/$MODULE/script.js | This script file should import all your script files. The compiled file can then be loaded from the /scripting/$MODULE/script.js url. To work properly the <script> should be imported with the type="module" attribute. |
/migrations | Contains the sqlx migrations. Either .sql migrations (up only), or .up.sql and .down.sql pairs to support up and down migrations. |
Quickstart
The quickest way to wrap your head around how quokka works in practice is probably the examples in the Quokka repository.
And the quickest way to set up a new quokka project is (for now) by copying the quokka-scratch folder from the Quokka repository.
As a single-file entrypoint you can reference the following code, wich is an excempt from the quokka-scratch project:
mod command; mod controller; pub mod entity; pub mod service; use quokka::state::State; use rust_embed::Embed as RustEmbed; #[derive(RustEmbed)] #[folder = "web/templates"] #[include = "*.hbs"] struct Templates; #[derive(RustEmbed)] #[folder = "web/templates"] #[include = "*.scss"] struct Styles; #[derive(RustEmbed)] #[folder = "web/templates"] #[include = "*.js"] struct JavaScript; #[derive(RustEmbed)] #[folder = "web/assets"] struct Resources; pub struct AppBundle {} /// This is provides the minimum to get a Bundle started. /// /// See the [quokka::bundle::Pouch] for available setup functions. impl<S: State + 'static> quokka::bundle::Pouch<S> for AppBundle { /// Find your config module from here fn from_config(_: &quokka::config::Config) -> quokka::Result<Self> where Self: Sized, { Ok(AppBundle {}) } fn configure_styles(&mut self, styles: &mut quokka::state::Styling) -> quokka::Result<()> { styles.register_embedded_styles::<Styles>(); Ok(()) } fn configure_scripts(&mut self, scripts: &mut quokka::state::Scripting) -> quokka::Result<()> { scripts.register_embedded_scripts::<JavaScript>(); Ok(()) } fn configure_templates( &mut self, templates: &mut quokka::state::Templating, ) -> quokka::Result<()> { templates.register_embedded_templates::<Templates>() } fn configure_resources( &mut self, resources: &mut quokka::state::Resources, ) -> quokka::Result<()> { resources.register_embedded_resources::<Resources>(); Ok(()) } fn get_migrations(&self) -> Option<sqlx::migrate::Migrator> { Some(sqlx::migrate!()) } } #[tokio::main] async fn main() -> quokka::Result<()> { Quokka::<DefaultState>::try_default()? .load::<AppBundle>()? .serve() .await }
If you are still missing out some basics, check out the States and Bundles chapters.
Config
Quokka uses TOML for it's configuration format. Inside the config there are "config modules" defined. These modules may or may not be used to configure substates. They can also be used by Bundles. Either way they always consist of a "module" field and a "config" field.
These are the Quokka builtin config modules
[[modules]]
module = "database"
config = { url = "postgres://$USERNAME:$PASSWORD@$DATABASE_HOST/$DB_NAME" }
# Can be omitted when passing in an FD for listening
[[modules]]
module = "listen"
config = { endpoint = "[::]:8765" }
[[modules]]
module = "migrations"
config = { autorun = true }
[[modules]]
module = "resources"
config = { additional_cache_control = [ "pubilc" ], max_age = 21600 }
[[modules]]
module = "mailer"
config = { host = "127.0.0.1", port = 1025, ssl_mode = "plain", from_name = "My Website", from_email = "my-website@localhost" }
Multiple configs
Quokka allows you to merge multiple config files into a single one by putting them, joined by : in the CONFIG_PATH environment variable.
Listening might be special
Quokka comes by default with the ability to take a listen on a filedescriptor which is passed in from an outer "launch" process (like when using systemd's .socket units). It utilizes listenfd library for that.
This abstraction allows an Quokka application to be running as a non-root/system user and without extra permissions while still being able to run on a privileged port.
Implement TryFromConfig
The TryFromConfig trait is there to build bundles and substates from config modules. There is no derive macro for it, but it's easy
enough to implement manually:
struct TestBundle {
configured_field: String,
}
// Technically this can also be skipped and the bundle it self can be Deserialized, but with more complex bundles they might tend to
// contain non-deserializable types, so you can right away go with a custom config struct for it.
#[derive(serde::Deserialize)]
struct TestBundleConfig {
configured_field: String,
}
impl quokka::config::TryFromConfig for TestBundle {
type Error = quokka::config::Error;
async fn try_from_config(config: &quokka::config::Config) -> quokka::Result<Option<Self>>
where
Self: Sized,
{
// This function will search a module called `test-bundle` and deserializes it into the `Testbundle`
config.get_module("test-bundle")
}
}
(Sub-)States
The state bundles all the substates which make up the Quokka application. The state has to implement the quokka::state::State. It should
also implement a quokka::state::ProvideState<Substate> for State for every substate. To make this easy both of these traits have a derive
macro allowing you to quickly build your custom state. In most cases though the quokka::DefaultState is probably good enough for you.
The state derive macros
State and ProvideState
There are derive macros for both traits required for implementing a custom state #[derive(State, ProvideState)]. The ProvideState
also implements the ProvideStateRef. This additional trait allows you to get references of the substate directly from a &State and
&mut State. Immutable and mutable references respectively. With this you can modify a substate to register and modify it to integrate
with your bundle.
#[derive(Clone, Default)]
struct MyState {}
#[derive(Clone)]
struct MoreComplexState {
name: String,
}
fn complex_builder(name: impl ToString) -> MoreComplexState {
MoreComplexState {
name: name.to_string(),
}
}
#[derive(Clone, State, ProvideState)]
struct CustomState {
/// A state built with a `Default::default()` call
#[state(default)]
pub state: MyState,
/// A state built by calling the `complex_builder("Alice")`
///
/// As the `builder` can by any expression you can but there whatever you like, it can be a function call, variable or whatever else you
/// like to have there.
#[state(builder = complex_builder("Alice"))]
pub complex_state: MoreComplexState,
// All the quokka states that might be needed
/// A state built with `quokka::config::TryFromModule` trait
///
/// All the quokka states implement this trait for simplicity, even if they are implementing the `Default` trait
pub templating: quokka::state::Templating,
pub styling: quokka::state::Styling,
pub scripting: quokka::state::Scripting,
}
FromState
The FromState builds a given state from sub-states that implement the ProvideState for a parent state (This might sound more complicated
then it actually is). Additionally fields again support a #[from_state(builder = EXPR)] for things that are not in a state or effectively
cannot be in a state (like a String).
This example assumes, that you have an otherwise working Quokka application running with the quokka::DefaultState.
#[derive(FromState)]
struct SomeService {
/// The `quokka::state::FromState` also accepts a `#[from_state(builder = EXPR)]` attribute to provide things like a string.
///
/// As the `FromState::from_state` method gets the state in as `state: &S`
#[from_state(builder = "Alice".to_string())]
name: String,
/// **Note**: usually a trait bound is introduced for every field in the struct. As soon as you provide it with a `builder` th is
/// behavior is disabled. You can provide additional trait bounds with the `bounds` field inside the attribute to fix this up
#[from_state(builder = MoreComplexState::from_state(state).name, bounds = "MoreComplexState: FromState<State>")]
complex_name: String,
/// This field is just to demonstrate that, as long as the type can be built from the applications state, it doesn't matter how deeply a
/// service is nested
_db: Database,
}
#[derive(FromState)]
struct NestedService {
service: SomeService,
}
impl NestedService {
fn get_response(&self) -> String {
format!("Hello {}", self.service.name)
}
}
The custom State extractor
Quokka brings it's own quokka::extract::State utility, which is similar to the axum::extract::State, but utilizes the
quokka::state::{ProvideState, FromState} traits. The Quokka extractor is still type checked though as it uses the routers' state.
Building on the example from before, the Quokka state extractor works like shown here
#[axum::debug_handler(state = quokka::DefaultState)]
async fn utilize_services(srv: quokka::extract::State<NestedService>) -> String {
srv.get_response()
}
A custom Substate
The State derive macro build's it's substates (if not defined by a "builder") using the quokka::config::TryFromModule.
The "module" in the try_from_module name, refers to a config module (for more info check the Configuration chapter).
impl quokka::config::TryFromModule for CustomState {
async fn try_from_module(_: &crate::config::Module) -> Result<Option<Self>>
where
Self: Sized,
{
// Ensure that the config want's to build this substate
if module.module.ne("custom_state") {
return Ok(None);
}
// Convert the raw config to a specific one, using serde
let config: CustomConfig = module.build_config()?;
// Build the substate
Ok(Some(Self))
}
}
Pouches / Bundles
Quokka bundles logic in so called Pouches. These contain everything that a library or application need to function. This includes database
logic, templates, form (or other backend) logic and the entirety of styling.
The pouches allow you to extend the base framework with whatever resources it supports.
To keep the Pouches and your libraries and applications updatable without a big hassle as soon as Quokka gets a new function, the Pouch
trait shall only get new callbacks added to it which come with default function bodies so that you can simply update and use the new functions
when you are ready. Or just completely skip it if you don't need it.
To let Quokka build your Bundle it also has to implement the quokka::config::TryFromModule trait. For an example check out the
Configuration chapter.
Available callbacks
The following hooks are available to extend the functionallity of Quokka and other bundles.
| Callback | Description |
|---|---|
get_router | Create your application's router. It may contain all your required layers, states and routes |
configure_router | Allows you to modify the global router which conatins all the routers from other bundles too. Only use it if necessary. |
configure_state | Configures the S State. The global state that Quokka uses. Here you interact with every other state, register your resources etc. Combined with a ProvideStateRef<YourState> you have easy access to every substate. The first-party, Quokka provided substates even come with traits that allow you to directly act on a S: ProvideStateRef<SubState>. |
run_setup_job | This allows you to initialize and run some job. This function can be used to either run some initialization (like database migrations) or spawn some daemon into the tokio runtime. |
Examples
The examples will provide you with some more details about the more complex callbacks.
Note: For further examples you can checkout the examples directory of the Quokko repository.
configure_state
This is most likely the most basic but still mostly used implementation of this function. Extracting a substate and register some resource on it.
impl<S> quokka::bundle::Pouch<S> for MyBundle
where S: quokka::state::ProvideStateRef<ThirdPartyState> {
fn configure_state(&self, state: &mut S) -> quokka::state::Result<()> {
// The ProvideStateRef trait implements a method giving you a mutable reference to the original substate allowing you to modify it
let third_party: &mut ThirdPartyState = state.provide();
third_party.register_a_thing("Thing");
Ok(())
}
}
Handler
The Quokka handlers provide you with interfaces to load and process data, it also comes with some input and output helpers for JSON and templating/HTML representation of this data.
DataLoader
The general idea is to have a DataLoader which loads data from a database or other sources and refines that data for displaying it.
Gathering of the data might be outsourced to some services to keep code clean as well as formatting it properly or merging it with
other data. It is most sensical to use it in GET routes as it cannot interact with the request body.
*Renderer
To use (or render) the data created by the DataLoader you can use the builtin TemplateRenderer or the JsonRenderer.
Example
Note: The provided get_router method is an excerp of a bundle/Pouch implementation.
#[derive(serde::Serialize)]
struct PersonData {
name: String,
age: u8,
}
#[derive(Clone, FromState)]
struct PersonDataLoader {}
impl<S: Send + Sync + Clone + 'static> DataLoader<S> for PersonDataLoader {
// The Loader still can get data from the request using axum extractors.
// But it has no access to the body or any extractor that would require it.
type Args = Path<String>;
// The dete does not require you to choose the representation later. It only has to implement `serde::Serialize`.
type Data = PersonData;
// The error type can be any. In this case our handler cannot fail so we take the `Infallible`.
// It only has to implement the `std::error::Error` trait which already allows us to `Display` it.
//
// In case of an error it will be attached as an extension to a generic server error which can be intercepted using a middleware.
type Error = Infallible;
// This part loads your data. As the `PersonDataLoader` implements the `FromState` and is built from the state when a requests enters
// the application it can make use of any kind of service or substate directly.
async fn load_data(&self, name: Self::Args) -> Result<Self::Data, Self::Error> {
Ok(PersonData {
name: name.to_string(),
age: 42,
})
}
}
fn get_router(&self) -> axum::Router<S> {
axum::Router::new()
.route(
"/api/v1/person/{name}",
JsonRenderer::<PersonDataLoader>::new().into(),
)
}
DataHandler
The DataHandler is supposed to handle the request body for a given request. The provided handlers ForwHandler and JsonHandler expect
you to provide a separate renderer (See the renderer section of the DataLoaders).
Example
Note: The provided get_router method is an excerp of a bundle/Pouch implementation.
use std::convert::Infallible;
use axum::{extract::Path, routing::post};
use quokka::{state::*, handler::{DataHandler, JsonHandler, JsonRenderer}};
#[derive(Clone, FromState)]
struct UpdatePersonDataHandler {}
#[derive(serde::Deserialize)]
struct UpdatePersonData {
age: u8,
}
impl<S: Send + Sync + Clone + 'static> DataHandler<S> for UpdatePersonDataHandler {
// Also here you can only use extractors that do not require the body as the body is being extracted by the handler as whatever input
// format it requires
type Args = Path<String>;
// The handler will parse the request data (eg. JSON) and Deserializes it into this data type, thus it needs to implement the
// `serde::Deserialize` trait
type Body = UpdatePersonData;
// Also this error type has to implement the `std::error::Error` trait, but instead of being returned directly to the client, the
// request will be forwarded to the `DataLoader` which then can extract the error as an extension as type `HandlerError<Error>` (where
// `Error` is this here specified error type).
type Error = Infallible;
// The `DataHandler` is only supposed to do action on the request body, so it's path has to match with the corresponding `DataLoader`
// which loads the data.
//
// As this types' name implies it, the here provided data can be extracted with an extension on the `DataLoader` but it is usually
// expected to construct the data only with the required info (through it's `Args` type) to ensure consistent view of this data.
type Extension = ();
async fn process_data(
&self,
path: Self::Args,
body: Self::Body,
) -> Result<Self::Extension, Self::Error> {
// self.service.update_user(path.0, body.age).await; // Update the user here
Ok(())
}
}
fn get_router() -> axum::Router<S> {
axum::Router::new()
.route(
"/api/v1/person/{name}",
post(JsonHandler::<UpdatePersonDataHandler, _>::new(JsonRenderer::<PersonalDataLoader>::new())),
)
}
This example shows the optimal use of a DataHandler and a DataLoader. For cases like a CreatePersonDataHandler, which would get it's
full data from the request and then gets persistet, thus created with a new path, this has to be different as a listing (the default action
for a route without identifier) does not make sense for a response when a new entity is created. For this usecase the implementation would
look like the following
use std::convert::Infallible;
use axum::Extension;
use quokka::{state::*, handler::{DataHandler, DataLoader}};
#[derive(Clone, FromState)]
struct CreatePersonDataHandler {}
#[derive(Clone)]
struct PersonIdentifier(i32);
#[derive(serde::Deserialize)]
struct CreatePersonData {
name: String,
age: u8,
}
#[derive(Debug, thiserror::Error)]
enum CreatePersonError {
#[error("You are too old: {0}")]
TooOld(u8),
#[error("You are too young: {0}")]
TooYoung(u8),
}
impl<S: Send + Sync + Clone + 'static> DataHandler<S> for CreatePersonDataHandler {
// Wo don't need args as we handle the full body
type Args = ();
// The handler will parse the request data (eg. JSON) and Deserializes it into this data type, thus it needs to implement the
// `serde::Deserialize` trait
type Body = CreatePersonData;
// Also this error type has to implement the `std::error::Error` trait, but instead of being returned directly to the client, the
// request will be forwarded to the `DataLoader` which then can extract the error as an extension as type `HandlerError<Error>` (where
// `Error` is this here specified error type).
type Error = CreatePersonError;
// Now we do want to attach the created Person's identifier so we can load it later
type Extension = PersonIdentifier;
async fn process_data(
&self,
_: Self::Args,
body: Self::Body,
) -> Result<Self::Extension, Self::Error> {
if body.age > 99 {
return Err(CreatePersonError::TooOld(body.age));
}
if body.age > 9 {
return Err(CreatePersonError::TooYoung(body.age));
}
// let id = self.service.create_person(body.name, body.age).await; // Update the user here
todo!() // this should return `id` - if we would have one
}
}
impl<S: Send + Sync + Clone + 'static> DataLoader<S> for CreatePersonDataHandler {
type Args = (Option<Extension<PersonIdentifier>>, Option<Extension<CreatePersonError>>);
// The usual Data type as described above
type Data = PersonData;
// This handler does nothing actually, so we don't need to specify an error
type Error = JsonError;
async fn load_data(
&self,
(identifier, error): Self::Args,
) -> Result<Self::Data, Self::Error> {
if let Some(Extension(error)) = error {
// We convert our error here because we use the same middleware for all of our JSON APIs which only outputs the current error
// return Err(JsonError(format!("{error}")));
}
if let Some(Extension(identifier)) = identifier {
// return Ok(self.service.load_person(identifier));
}
todo!()
}
}
Here we use the same struct for loading and handling our data, as this whole action is not very reusable anyway.
Templating & Styling
Quokka comes with Handlebars as template engine, uses Sass/SCSS to make working with CSS a bit more comfortable and relies on JS modules to make working with JavaScript feel modern but without the modern (and mostly painful) JavaScript build stack.
To use the templating, styling, scripting and resources properly you have to load the quokka::templating::TemplatingPouch.
Templating
For templating there is the quokka::state::Templating substate. The usage of it is better documented in the
Template Helpers chapter.
To get your templates into Quokka in the first place though, you use the configure_state callback of the Pouch trait.
Here you can not only register your templates, but also set up custom handlers, blocks and partials.
#[derive(rust_embed::Embed)]
struct Templates;
struct TemplatingExample;
impl<S> Pouch<S> for TemplatingExample
where
S: Send + Sync + Clone + 'static,
S: ProvideStateRef<Scripting>,
S: ProvideStateRef<Styling>,
S: ProvideStateRef<Templating>, {
fn configure_state(&mut self, state: &mut S) -> quokka::pouch::Result<()> {
use quokka::templating::TemplatingStateExt;
state.register_templates::<Templates>()?;
// Or alternatively also this (description in the Template Helpers chapter)
// state.register_templates_aliased::<Templates>()?;
// Registering a helper
handlebars::handlebars_helper!(debug: |value: serde_json::Value| { println!("{value:#?}") });
templates.register_helper("debug", Box::new(debug));
Ok(())
}
}
Styling
To make styling even more comfortable Quokka renders all styling files using grass (a Sass compiler for Rust). By that your templates can
do the usual Sass things, like macros, variables and nesting (although variables and nesting are by now also natively supported in CSS).
But still, it gives you more power with macros when you have to do otherwise more tedious stuff (like loading fonts on your own).
Styling is done in the configure_styles callback:
#[derive(rust_embed::Embed)]
struct Styling;
struct StylingExample;
impl<S> Pouch<S> for StylingExample
where
S: Send + Sync + Clone + 'static,
S: ProvideStateRef<Scripting>,
S: ProvideStateRef<Styling>,
S: ProvideStateRef<Templating>, {
fn configure_state(&mut self, state: &mut S) -> quokka::pouch::Result<()> {
use quokka::templating::TemplatingStateExt;
state.register_styles::<Styling>()?;
state.add_merged_style_group("my-module", "my-module/style.scss");
Ok(())
}
}
In this example you can see the use of "merged styles". This functionallity is intended to be used to collect styles across bundles and load a stylesheet from one endpoint. Other bundles can merge their own styles into your style group to bring their own styling into your frontend so that you don't have to find a way for that.
Styles can be retrieved from the following endpoints:
/styling/my-module/style.scss- wheremy-module/style.scsslies withing yourStylespath and can be anything that is registered inside of it/styling/group/group_name- where "group_name" is the name of the registered group./styling/style.css- This returns the compiled style for the "default" merged style group
Scripting
For the scripting Quokka uses JavaScript Modules. These make it easy and quick to import and export JavaScript functionality between scripts.
Scripting is configured in the configure_scripts callback:
#[derive(rust_embed::Embed)]
struct JavaScript;
struct ScriptingExample;
impl<S> Pouch<S> for ScriptingExample
where
S: Send + Sync + Clone + 'static,
S: ProvideStateRef<Scripting>,
S: ProvideStateRef<Styling>,
S: ProvideStateRef<Templating>, {
fn configure_state(&mut self, state: &mut S) -> quokka::pouch::Result<()> {
use quokka::templating::TemplatingStateExt;
state.register_scripts::<JavaScript>()?;
state.add_merged_script_group("my-module", "my-module/style.scss");
Ok(())
}
}
The "merged script" here is the same functionality as with the Styling one. The only difference is, that you have to take care of the import path. The path either has to be an absolute path, or if it is a relative one, it has to start with "./". You can also sot up import maps, but Quokka does not bring them (yet) on it's own.
Here again are the import paths
/scripting/my-module/script.js- wheremy-module/script.jslies withing yourStylespath and can be anything that is registered inside of it/scripting/group/group_name- where "group_name" is the name of the registered group./scripting/style.js- This returns the script for the "default" merged script group
The last important part for the scripting is, that you have to include the type="module" attribute with your <script> include.
Template Helpers
Quokka comes with a variety of helpers for templating making it easy to quickly build and extend a web application keeping functionality separated from each other.
NOTE
Some examples come up with a random a
fn get_router(&self) -> axum::Router<S>function as an entrypoint. The function has to be seen as the snippet from aPouch. The examples also assume that templates and other resources are already registered if not explicitely shown. I also assume, that by now, you got to know how the application is started, otherwise check out the Quickstart chapter.Most code here is similar to the one represented in the Handler chapter and much better documented there.
For the full, tested example about the template helpers check the examples/data_handlers.rs
Static templates
You can define a simple, static template with the StaticTemplate helper. While it renders a static template (which sounds useless so far), it also
allows you to provide some static data to work with using the StaticTemplateContext.
use quokka::handler::templating::StaticTemplate;
fn get_router(&self) -> axum::Router<S> {
axum::Router::new()
.route("/static", StaticTemplate("test-template").into())
.route(
"/static/greeting",
StaticTemplate("greet-name").into_with_context(serde_json::json! {{
"name": "Static"
}}),
)
}
TemplateRenderer
The TemplateRenderer is a bit more invovled. While being registered in the same way to the route, they require you to provide a struct
which implements the DataLoader trait.
A DataLoader can range from very simple up to a complex handler combining any axum extractor with some internal services provided
through a state (see the states#FromState section for a glimpse of how a service can be implemented using
the global state).
#[derive(Clone, FromState)]
struct NameParamDataLoader {}
impl<S: Send + Sync + Clone + 'static> DataLoader<S> for NameParamDataLoader {
type Args = Path<String>;
type Data = TemplateData;
type Error = Infallible;
async fn load_data(&self, name: Self::Args) -> Result<Self::Data, Self::Error> {
Ok(TemplateData {
name: name.to_string(),
})
}
}
// [...]
fn get_router(&self) -> axum::Router<S> {
axum::Router::new()
.route(
"/greet/{name}",
TemplateRenderer::<NameParamDataLoader>::new("greet-name").into(),
)
}
This can feel a bit restricting and does not allow to handle methods other than GET yet, but this general way of constructing the data
allows you not only to render templates but in combination with the JsonRenderer you can even present your data as JSON (other Renderer
structs can be implemented of cause too).
FormHandler
Now we want to handle other requests too (Like POST, PUT, DELETE or other HTML verbs), for this we have the FormHandler or to be
more acurate, for this we implement the DataHandler trait. It allows us to accept some Body and forward data (through axum extensions)
to a renderer.
The first step is to get some struct and implement the FormHandler trait to it:
#[derive(Clone, FromState)]
struct PostDataHandler;
#[derive(serde::Deserialize)]
struct InputData {
name: String,
}
#[derive(Clone)]
struct TemplateData {
name: String,
}
impl<S: Send + Sync + Clone + 'static> DataHandler<S> for PostDataHandler {
type Args = ();
type Body = InputData;
type Error = Infallible;
type Extension = TemplateData;
async fn process_data(
&self,
_: Self::Args,
body: Self::Body,
) -> Result<Self::Extension, Self::Error> {
Ok(TemplateData { name: body.name })
}
}
This very basic handler just takes a parameter from the body and passes it on as an extension. But in the same place it could also grab the
Session throug the Args type, authorize a user and if applicable store the body data into a database.
So far we can receive data, but we also need to display something to the user. For this our PostDataHandler has to implement the
DataLoader trait too (actually displaying the data can also be done on a different struct as long as the response extension is compatible)
Lets implement some displaying, including error handling:
impl<S: Clone + Send + Sync + 'static> DataLoader<S> for PostDataHandler {
type Args = (
Option<Extension<TemplateData>>,
// The error (`Infallible` in this case) has to match the error of the one emitted in the `DataHandler` implementation
// Multiple extensions can of cause be expected for different handlers
Option<Extension<quokka::handler::HandlerError<Infallible>>>,
);
type Data = TemplateData;
// This error can be different though.
type Error = Infallible;
async fn load_data(&self, (data, error): Self::Args) -> Result<Self::Data, Self::Error> {
if let Some(error) = error {
match error.0 {
// This error is being emitted when the body could not be parsed in the Handler.
// This method of handling it should not be presented, but shows the possibilities
quokka_handler::HandlerError::DataExtractorRejection(_) => {
return Ok(TemplateData {
name: String::from("Unable to extract Body from request"),
})
}
// This catches the `Infallible` (error variant of the `DataHandler`) or an error with extracting the request parameters
// which cannot happen in this case though as we don't extract any
error => panic!("We got some unexpected error: {error:?}"),
}
}
if let Some(data) = data {
return Ok(data.0);
}
Ok(TemplateData {
name: String::from("empty"),
})
}
}
Now this handler can output some error information in case some error ocurred. To finally register the route we can simply combine the
FormHandler with a TemplateRenderer:
fn get_router(&self) -> axum::Router<S> {
axum::Router::new().route(
"/greet",
FormHandler::<PostDataHandler, _>::new(TemplateRenderer::<PostDataHandler>::new(
"greet-name",
))
.into(),
)
}
Handlebars
Quokka uses Handlebars for it's template engine, for it's simlplicity and flexibility.
Handlebars helper
The Handlebars engine in Quokka provides some additional helpers on top of the built-in helpers
Quokka helpers
version- Prints your crates version (for example to cache-invalidate resources when a new version in deployed)concat- Concats any amount value that can be represented as a stringarray- Allows to compose an array from it's parameters (eg.(array 1 2 3 ... ))object- Allows to compose an objects from it's named parameters (eg.(object a=1 b=2 ... ))add,sub,mul,div- Mathematical operationslen,is_empty- Operations to check the length (or length = 0) of strings or objectsdebug- Debug-prints ("{:#?}") a statement to the console with the log level "Debug"markdown- Renders markdown from a variable given in themarkdown=keysafe_markdown- Does the same as themarkdownhelper, but does not escape the raw markdown input
Builtin blocks
if- Evaluates a condition and returns either the value if true or the default value if false.unless- Evaluates a condition and returns the default value if the condition is true, otherwise returns the value.each- Iterates over an array or object and applies a function to each item.with- Wraps the current context with a new context, allowing you to reuse variables in your templates.lookup- Looks up a variable in the context using its key.raw- Outputs raw content directly to the HTML output.
Builtin helpers
eq- Checks if two values are equal.ne- Checks if two values are not equal.gt- Checks if a value is greater than another.gte- Checks if a value is greater than or equal to another.lt- Checks if a value is less than another.lte- Checks if a value is less than or equal to another.and,or, andnot- Perform logical operations on conditions.log- Logs information about the rendering process.
String Manipulation
lowerCamelCase,upperCamelCase,snakeCase,kebabCase,shoutySnakeCase,shoutyKebabCase,titleCase, andtrainCase: Convert strings to different case formats.
About template paths
While the simplest method of registering a template set is by using the Templating::register_embedded_templates, there is also the
Templating::register_embedded_templates_aliased_partials.
If you follow a setup for your template/styling needs like described in the Structure chapter, this will be helpful for you.
The described structure suggests something like this:
web/templates/module_name/
└── partials
└── component_name
├── component_name.html.hbs
├── component_name.scss
└── component_name.js
Using this structure will force you to make your partial imports pretty messy, like this
{{>module_name/partials/component_name/component_name.html.hbs}}
But when using the Templating::register_embedded_templates_aliased_partials method for registering your template resource, you can skip the module_name part and the repetition for the component_name.
This results in a way smaller path.
{{>partials/component_name}}
{{>module_name/partials/component_name}} {{! also valid and scoped to the module }}
A nice side effect of including partials from the partials directory is, that other modules can add on to your partials. Lets assume the following:
- Module 1 has
module_1/partials/my_component/my_component.html.hbs - It uses it as
{{>partials/my_component}}
You can now go and register your template as my_module/partials/my_component/my_component.html.hbs and add on to the other template
Something before their partial
{{>module_1/partials/my_component}}
Something after their partial
Now this might depend on the loading order, but if everything is correct, the including template will see your my_module/* template and
use that instead of the original one.
If you want to prevent it from doing this, you can of cause always put your module name in the include {{>module_1/partials/my_component}}
and circumvent this possibility.
Error Handling
While neither Quokka nor any of it's sub crates make displaying errors very easy (for now) the recommanded way so far is to create a
middleware which checks for a Extension<Error> on the created response and act according to it. This middleware might only be attached
to your router, returned by the get_router method as you will unlikely handle someone else's errors. And in the case someone else uses
your errors you can make your error handling middleware public.
Command
Commands are a powerful way of extending you application or library beyond the web. Providing a way to run maintenance tasks from a console or trigger recurring jobs through an external "cron" runner.
Getting started
Creating a command handler
Getting started with a command is pretty easy. You simply have to create a new struct and implement the quokka::state::CommandHandler
trait.
The handler struct should implement the FromState trait too, so that it can be easily built by the global state.
use clap::Command;
use quokka::state::{CommandHandler, FromState};
#[derive(FromState)]
struct HelloWorldCommandHandler;
impl CommandHandler for HelloWorldCommandHandler {
fn args() -> clap::Command {
clap::Command::new("example:hello-world")
}
async fn call(self, _args: clap::ArgMatches) -> quokka::Result<()> {
println!("Hello World");
Ok(())
}
}
Register the command handler
With the handler created you simply have to add it within your bundle
struct HelloWorldBundle;
impl<S: Clone + Send + Sync + 'static> Pouch<S> for HelloWorldBundle {
fn configure_commands(&self, commands: &mut Commands<S>) -> quokka::Result<()> {
commands.register_command::<ListSessionsCommand>();
Ok(())
}
}
Create a CLI binary
Additionally you now have to initialize Quokka differently. Instead of calling the serve function you want to call the execute_command
function which will take care of collecting the commands and dispatching the commands from arguments. This requires either a second binary
or you have to wrap some parsing around it.
#[tokio::main]
async fn main() -> quokka::Result<()> {
quokka::Quokka::<DefaultState>::try_default()?
.load::<HelloWorldBundle>()?
.execute_command()
.await
}
A note on command names
The command name will be the name of the CLIs subcommand. It is recommanded to use the same bundle namespace as you would use for templates or other resources to prevent conflicts from happening.
And while it is possible to make a $BUNDE_NAME command and organize subcommands using clap, I'd like to recommend to use a subcommand per
task that should to be done. This reduces the complexity of your command handler and matching the proper subcommands and colling it with clap.
Database
Working with the database is enabled by default using the database feature.
Most web applications do need a database. Uses sqlx for making requests to a PostgreSQL database. To make this even simpler it provides
you with some helpers to make it very quick and easy to interact with a database.
The "PaginatedSearch" helper
Quokka provides a way for you to simply query a paginated and extensible set of entites. This is done by SearchCriterias and the
PaginatedSearch service. All it needs to be constructed is State: ProvideState<Database>.
The helper itself
The Paginated search allows you to query any kind of entity with fixed set of operators.
use quokka::helper::database::{PaginatedSearch, EqCriteria, PaginatedQuery};
use quokka::Result;
use axum::extract::{Path, Query, State, Json};
async fn my_handler(
search: State<PaginatedSearch>,
Path(author_name): Path<String>,
Query(pagination): Query<PaginatedQuery>
) -> impl Result<Json>) {
let output = search.search("article", EqCriteria("author", author_name), pagination).await?;
Ok(Json(output))
}
The entity/table name (first parameter) is always required and needs to be a &'static str. This limitation is in place so that you (the
developer) can make sure, that there comes no SQL injections across your nice code.
The criteria and pagination fields can be filled with None values if you don't want to use them.
The SearchCriteria
The SearchCriteria is a trait which extends the query builder with whatever search statement is needed in the WHERE clause to complete
the search. As of now there are the following Criteria objects available: AndCriteria, OrCriteria, EqCriteria, NeCriteria,
LikeCriteria, GtCriteria, GteCriteria, LtCriteria, LteCriteria, InCriteria, NotNullCriteria, IsNullCriteria.
The names are supposed to be quiet explanatory if you are used to logical operations and/or SQL queries. But if you need a clearer explanation
of them check out Quokka on docs.rs.
If you need some custom criteria feel free to dig into the SearchCriteria and maybe reference some other, provided criteria structs to
figure out how it is implemented and implement it for whatever struct you want.
Query macros
There are some attribute macros to quickly and easy get you going with some custom database queries.
Fetch one
Create a repository function which fetches data from a query using the sqlx::query::Query::fetch_one method.
It will fetch a single row, which might not exist.
use quokka::{Result, helper::database::query_one, state::Database};
#[derive(sqlx::FromRow)]
struct User {
id: i32,
username: String,
}
#[query_one(query = "UPDATE \"user\" SET username = {username} WHERE id = {id} RETURNING *", write)]
async fn update_username(database: Database, id: i32, username: &str) -> Result<User>;
Fetch optional
Create a repository function which fetches data from a query using the sqlx::query::Query::fetch_optional method.
It will fetch a single row, which might not exist. When using the quokka::Result for the return result though, it
will return an error with 404 if the query_one macro is used and no row exists.
use quokka::{Result, helper::database::query_optional, state::Database};
#[derive(sqlx::FromRow)]
struct User {
id: i32,
username: String,
}
#[query_optional(query = "SELECT * FROM \"user\" WHERE id = {id}")]
async fn update_username(database: Database, id: i32) -> Result<Option<User>>;
Fetch all
Create a repository function which fetches data from a query using the sqlx::query::Query::fetch_all method.
It returns all fetched rows.
use quokka::{Result, helper::database::query_all, state::Database};
#[derive(sqlx::FromRow)]
struct User {
id: i32,
username: String,
}
#[query_all(query = "SELECT * FROM \"user\"")]
async fn update_username(database: Database, id: i32, username: &str) -> Result<Vec<User>>;
Execute without data
Create a repository function which executes a query using the sqlx::query::Query::execute method.
It will return the count of affected rows.
use quokka::{Result, helper::database::execute, state::Database};
#[derive(sqlx::FromRow)]
struct User {
id: i32,
username: String,
}
#[execute(query = "UPDATE \"user\" SET username = {username} WHERE id = {id}", write)]
async fn update_username(database: Database, id: i32, username: &str) -> Result<u64>;
Arguments (all macros)
The repository macros all support the same set of arguments:
query- The SQL query which will be executedwrite- A flag that makes the function use the read-write connectiondb_field- An expression to get the DB connection
A note on query
You can bind values to to the query at a given position by wrapping a variable in {} (eg. {name}). Prefixing the variable with a #
(like {#name}) allows you to streight up push the value into the query without escaping and binding it. Beware that this can cause SQL
injections if not handled with proper care and attention.
A note on write
To make effective use of multi-node database clusters which come with read-only
replicas this macro, by default takes the connection of the quokka::state::Database::ro
method. While this might be the same as the quokka::state::Database::rw
(dependending on the configuration) it is supposed to be a read only connection.
Passing the write attribute calls the Database::rw instead.
A note on db_field
By default the macro uses the database field (of a struct) or argument.
It will use a struct field if a receiver (&self) is defined, otherwise expects an argument
to the function, that is called database. Either way the type of the database has to be
the Database substate. If you directly want to pass the connection into your function or
struct, you can define an expression using this attribute (eg. db_field = "&self.ro_connection").
Return
The exact return type is influenced by the repository macro that is used, but generally said
the return type must be a Result with the Result::Err variant implementing
std::convert::From<sqlx::Error>.
So you can directly return a sqlx::Error.
The quokka::Error also implement the std::convert::From, so you can also this one. It will
resolve the sqlx::Error::RowNotFound variant to a 404 error, which makes it easy to just
return a query_one result from a controller.
The Result::Ok varies by the used repository function.
- The
executeonly is expected to be au64indicating the amount of affected rows. - The
query_oneexpects a struct that implements asqlx::FromRow. - The
query_allexpects a struct that implements thesqlx::FromRowtrait which can bestd::iter::Iterator::collected from anIterator. - The
query_optionalexpects anOptionof a struct that implements thesqlx::FromRow.
A note on Repositories
Repositories are supposed to make interfacing with the database even easier. The examples so far assumed, that you have a function to which you always have to pass the database connection too. We can keep the database connection in a different spot though by building a repository (getting into a somewhat more object oriented territory).
A repository is nothing special, just a struct with some functions in it. And to make it even more useful a quokka::state::FromState
derive can be applied to make it simpler to handle.
use quokka::{Result, helper::database::*, state::{Database, FromState}};
#[derive(sqlx::FromRow)]
struct User {
id: i32,
username: String,
}
#[derive(FromState)]
struct UserRepository {
database: Database,
}
impl UserRepository
{
#[query_all(query = "SELECT * FROM \"user\"")]
async fn get_users(&self) -> Result<Vec<User>>;
#[execute(query = "UPDATE \"user\" SET username = {user.username} WHERE id = {user.id}", write)]
async fn update_username(&self, user: User) -> Result<u64>;
}
And with that simple struct we can just use this repository everywhere and modify our database.
The "BaseRepository"
To get a minimal, standard interface to other modules, Quokka exports the BaseRepository trait. A set of very basic function used to
interface with an entity.
impl BaseRepository for UserRepository {
type Entity = User;
type PkType = i32;
#[quokka::helper::database::query_all(
query = "SELECT * FROM \"user\" ORDER BY {#order_by} {#direction.to_string()} LIMIT {#page_size} OFFSET {#page_size * page}"
)]
async fn get_entities(
&self,
page: i32,
page_size: i32,
order_by: &'static str,
direction: PaginationOrder,
) -> quokka::Result<Vec<Self::Entity>>;
#[quokka::helper::database::query_one(query = "SELECT * FROM \"user\" WHERE id = {pk}")]
async fn get_entity(&self, pk: Self::PkType) -> quokka::Result<Self::Entity>;
#[quokka::helper::database::execute(
query = "UPDATE \"user\" SET username = {entity.username} WHERE id = {entity.id}",
write
)]
async fn update_entity(&self, entity: Self::Entity) -> quokka::Result<u64>;
#[quokka::helper::database::query_one(
query = "INSERT INTO \"user\" (id, username) VALUES ({entity.id}, {entity.username}) RETURNING *",
write
)]
async fn create_entity(&self, entity: Self::Entity) -> quokka::Result<Self::Entity>;
#[quokka::helper::database::execute(query = "DELETE FROM \"user\" WHERE id = {pk}", write)]
async fn delete_entity(&self, pk: Self::PkType) -> quokka::Result<u64>;
}
Migration
Having a database is one thing, having it being setup automatically and it being migrated during updates is the other thing. While this can
be done in the run_setup_job method, you can simple act on the MigrationState and run the add_migrator method on it.
To run database migrations you have to load the quokka::migration::MigrationPouch.
Example
use quokka::migration::{MigrationState, MigrationStateExt};
fn configure_state<S: Clone + Send + Sync + 'static + ProvideStateRef<MigrationState>>(state: &mut S) -> quokka::pouch::Result<()> {
state.add_migrator(sqlx::migrate!());
Ok(())
}
Session
Quokka comes with an extensible Session type. It can be extracted using an Extension and be returned in a response as it implements the
IntoResponseParts trait. When present in a response, the session layer will automatically save the updated session.
Using a session
Here we get a simple session handler which increments a counter as soon as the user hits the route
#[derive(Default, serde::Deserialize, serde::Serialize)]
struct SessionCounter {
counter: u32,
}
#[axum::debug_handler]
async fn write_session(mut sess: Extension<Session>) -> impl IntoResponse {
let counter = sess.get_extension::<SessionCounter>("counter").unwrap_or_default();
counter.counter += 1;
let response = format!("You visited the page {} times", counter.counter);
sess.add_extension("counter", counter).expect("Write the updated counter back to the session");
(sess, response)
}
This will increment the counter as soon as the user refreshes the page and displays the visit count to the user. The session will be stored to the database when it gets returned by the handler.
Job
As already mentioned in the Pouch chapter, Quokka provides you with a run_setup_job method in your Pouch to run some async
setup tasks.
To build on top of that and to make it more Quokka like, there is the quokka::job module, coming with the quokka::job::Job trait which
allows you to create a job as a separate struct allowing you to keep your code split up and reusable. It does not change the behaviour or
time when jobs are being run, but it gives it a cleaner interface. It also allows other modules to expose some initial setup tasks which
can be reusable and accept some input data.
To use jobs you have to load the quokka::job::JobPouch.
Example
This example shows the bare minimum of quokka jobs, showing it's reusability and keeping you away of this scary return type of the original
run_setup_job() -> Pin<Box<dyn Future<Output = Result<()>> + Send>> function.
use std::convert::Infallible;
use quokka::job::{Job, Jobs, JobStateExt};
// The job can be built with some data, different for each module, allowing them to use the same job for their purpose.
struct GreetModule(String);
impl<S: Clone + Send + Sync + 'static> Job<S> for GreetModule {
type Error = Infallible;
async fn handle(&self, _: &S) -> std::result::Result<(), Self::Error> {
println!("Hello {}", self.0);
Ok(())
}
}
fn configure_state<S: Clone + Send + Sync + 'static + ProvideStateRef<Jobs<S>>>(state: S) -> quokka::pouch::Result<()> {
let mut jobs: Jobs<S> = state.provide_mut();
jobs.register_job(GreetModule(String::from("My module")));
Ok(())
}
Quokka Admin
The Quokka Admin project provides an extensible UI for managing your application and especially entities.
It allows you to extend various parts with your custom UIs, widgets and entities. All that you need is a URL which provides valid HTML to display it in whatever parts of the Admin UI that you want to extend.
This documentation part expect's you to already know how Quokka works and uses parts that were mentioned as optional parts like custom states or the command system
Usage
To use Quokka Admin add the quokka_admin::state::AdminState to your custom state and load the quokka_admin::AdminBundle. You now can
open the admin-ui under /admin.
#[derive(Clone, State, ProvideState)]
pub struct YourCustomState {
// ...,
admin_state: quokka_admin::state::AdminState<Self>,
// ...,
}
#[tokio::main]
async fn main() -> quokka::Result<()> {
quokka::Quokka::<YourCustomState>::try_default()?
// ...
.load::<quokka_admin::AdminBundle>()?
// ...
.serve()
.await
}
To create a user you have to start up your applications cli with the quokka-admin:user:admin:create <username> <email> --password <password> command (if no --password argument is provided, the command will ask you for a password).
Getting started
To get started with adding your custom parts to the UI you want to set up the configure_state callback of your bundle to set up things on
the AdminState like shown here
impl<S> Pouch<S> for YourCustomState where
/// ...
S: ProvideStateRef<AdminState<S>>,
/// ...
{
/// ...
fn configure_state(&mut self, state: &mut S) -> quokka::Result<()> {
/// ...
// If you just use the `provide()` function you will get a `Clone` of the AdminState to which the changes will not make it to the actual instance of the AdminState
let admin: &mut AdminState<_> = state.provide_mut();
///...
}
/// ...
}
The documentation here is barely done. For now check the Quokka Admin examples to see everything that you can do with the Quokka Admin.
Navigation
To integrate your bundle with Quokka Admin you want to set up your own Navigation section in the sidebar. To do this you use the AdminState ::add_navigation method an pass a AdminNavigationGroup into it. You can order your group with the AdminNavigationGroup::order function.
Lower numbers will be displayed first. The default is 0 and the "Quokka Admin" group is at -100.
Specifying multiple groups with the same name will case them to be merged, keeping the order of the first registered one.
Registering an Entity will require you to also provide a navigation group name. This is because it will register NavigationItems with this
group name. By registering this group before the entity you can keep some ordering in there.
While, technically, there is no limit to nesting NavigationItems, it's only actively styled an tested for a single sub group.
Widgets
Widgets are used on the Quokka Admin Dashboard and in the sidebar. To control these you use the
quokka_admin::data::dashboard_widget::AdminDashboardWidget and quokka_admin::data::sidebar_widget::AdminSidebarWidget respectively.
You would use the AdminState::add_sidebar_widget and AdminState::add_dashboard_widget to add the widgets to whatever slot you want to
add them. The Admin Widget structs come with a htmx constructor which takes a URL to which they will make a request. The response should
return valid HTML which will then be inserted to their slots. With a .hx_trigger("load, every 1s") you can also set up a refresh for every
second in case you want to have your widget refreshed.
Forms / Listing
An essential part of the Quokka Admin is, that it provides an easy way for adding UI for managing whatever entity you want. This work the easiest for entities that reside in PostgreSQL, because the provided derive macros already build the queries for them, but you can also simply pass on your own function to store the data in whatever other Database you like and have access to through your state.
Forms (AdminCreateForm & AdminUpdateForm)
Each form item needs it's struct, which also implement the serde::Deserialize, serde::Serialize and if sqlx for the Database is used,
also the sqlx::FromRow trait.
From there on you simply derive the AdminCreateForm and AdminUpdateForm traits and specify what you need.
Examples
In this example the minimum set of attributes is used, so you always provide the entity_name and for the update form you have to provide a
primary_key.
#[derive(Clone, Debug, quokka_admin::service::AdminCreateForm, serde::Deserialize, serde::Serialize, sqlx::FromRow)]
#[create_form(entity_name = "test")]
pub struct TestEntityCreateForm {
name: String,
age: i32,
}
#[derive(Clone, Debug, quokka_admin::service::AdminUpdateForm, serde::Deserialize, serde::Serialize, sqlx::FromRow)]
#[update_form(entity_name = "test")]
pub struct TestEntityUpdateForm {
#[update_field(primary_key)]
id: i32,
name: String,
age: i32,
}
Each field can also have a broader set of attributes, and also the Form itself can be supplied some more settings, here is a full example of which attributes you have and can be used to modify the form.
#[derive(serde::Deserialize, serde::Serialize, sqlx::Type)]
pub enum ExampleEnum {
One,
Two,
Three,
}
#[derive(quokka_admin::service::AdminCreateForm, serde::Deserialize, serde::Serialize, sqlx::FromRow)]
#[create_form(
entity_name = "test",
create_query = some_persisting_function(state).await?, // This field allow you to provide a custom function to persist the new entity. You have access to the full state object and you can return a future which resolves into a quokka::Result<()>
)]
pub struct TestEntityCreateForm {
#[create_field(required)] // Makes the field required, also supported in the update form
name: String,
#[create_field(label = "Your Age")] // The label for the input, also valid in the update form
age: i32,
#[create_field(default = 0)] // The default value for the input, also valid in the update form
default_value: i32,
#[create_field(name = "type")] // Overwrites the name of the field similar to how the serde rename attribute works, also available in the update form
#[serde(rename = "type")]
typ: String,
#[create_field(field = SelectField::new("custom_input", "Custom Enum Input") // Sets the field for the frontend explicitely. See the fields section for details on fields
.add_option("One", "One")
.add_option("Two", "Two")
.add_option("Three", "Three"))]
custom_input: ExampleEnum,
}
// For the fields on the update form we only have the `primary_key` as additional attribute, the rest just works as described for the create form
#[derive(Clone, Debug, quokka_admin::service::AdminUpdateForm, serde::Deserialize, serde::Serialize, sqlx::FromRow)]
#[update_form(
entity_name = "test",
get_query = some_getter_function(state).await?, // this allows you to get the entity from some other place then the database. You have access to the full state and you are expected to return a quokka::Result<Self>
update_query = some_updating_function(state).await?, // this allows you to update the entity at some other place then the database. You have access to the full state and you can return a future which resolves to a quokka::Result<()>
)]
pub struct TestEntityUpdateForm {
#[update_field(primary_key)]
id: i32,
}
Listing
The listing follows pretty much the same pattern as the Form. It just uses a different derive macro and has some different attributes. But
the requirements (serde, sqlx etc.) are the same
Example
Here is an example explaining all supported attributes to the listing
#[derive(quokka_admin::service::AdminListing, serde::Deserialize, serde::Serialize, sqlx::FromRow)]
#[listing(
entity_name = "test", // This one is always required
get_entity = getter_for_all_entities(pagination: quokka::helper::database::PaginationQuery, search: Option<String>, state: &S), // This query should provide a list of entities. In the best case even respecting the pagination and search fields. As all the callbacks it can be a future and result in a quokka::Result<Vec<Self>>
get_one_entity = getter_for_a_single_entity(state: &S, PrimaryKeys), // This should return a single entity. It the primary keys will be collected from the fields having a `primary_key`. The output should be a future with quokka::Result<Self>
delete_entity = remove_a_single_entity(PrimaryKeys, state: &S), // This query should delete a single entity. It gets the primary keys the same way and is supposed to return a quokka::Result<()>
)]
pub struct TestEntityListing {
#[listing_field(primary_key)] // You always need at least one primary_key field
id: i32,
#[listing_field(field = NumberField::new("age", "Age"))], // This expects a `quokka_admin::service::page_loader::ListingColumn`. The most interesting part is, that it provides a template which can be used to map the value to something else.
age: i32,
#[listing_field(searchable)] // You can mark fields as "searchable". This attribute will be utilized with the generated get_query which utilizes the PaginatedSearch helper of quokka.
name: String,
}
Fields
The derive macro will try to detect the best Field type for forms based on the Rust type of a field. So numeric types will resolve to the
NumberField, bool will be translated to a CheckboxField and the String defaults to a TextField. For every other usecase (like
passwords) you have to specify the Field on your own.
The default, available fields are located at:
quokka_admin::service::form_builder::fields::{
TextField,
PasswordField,
NumberField,
CheckboxField,
SelectField,
HiddenField,
DisplayField,
HtmlInputField,
};
They all provide a new(name, label) constructor. Using this will overwrite whatever is set in the form attibutes.
SelectField
There is a special case for the SelectField which additionally provides a add_option(label, value) method so that you can specify your
options. It also provides a style(style) field which takes a quokka_admin::service::form_builder::fields::SelectFieldStyle enum allowing
you to choose either a combobox or radio buttons for the selection.
HtmlInputField
Then there is also the HtmlInputField which allows you to set a custom value for the type= attribute in the HTML <input> using the
set_type(type) function and setting any arbitrary attribute using the set_attribute(name, value) function.
Custom Fields
Of cause you have the option to provide your own fields. For this you simply have to create a new field type and implement the
quokka_admin::service::form_builder::FormField trait.
The following functions I consider self explanatory
fn template(&self) -> String;
fn name(&self) -> String;
fn label(&self) -> String;
fn default(&self) -> Option<String> {
None
}
fn required(&self) -> bool {
false
}
The fn additional_options(&self) -> HashMap<String, serde_json::Value> can be used to provide some custom data with your field, for
example to be used with the template. The fn processor(&self, _state: &S) -> Option<Box<dyn FormFieldPreProcessor + Send + Sync>> allows
you to provide a custom FormFieldPreProcessor where you can inject custom logic to query for values that can be used in a <select> input
for example or a searchable text input.
Authentication, Authorization & Login
Quokka Admin comes with a pretty flexible authentication system which splits up functions for Authentication & Authorization. Additionally there is a separate way to handle/validate the login for users.
Login
That's the easiest part, all you have to do is to implement the quokka_admin::middleware::AdminLoginProvider for a struct that can handle
logins. It comes with the async fn do_login(&self, login_data: &LoginData) -> quokka::Result<Option<LoginResult>> which gets you the
entered username and password and expects you to return a LoginResult which will be stored in the session. The first login provider that
returns a LoginResult will be taken for granted and allows the user to login.
Quokka Admin already comes and registers it's own login provider based on PostgreSQL.
Example
This example allows a user with the username "Test" and password "Password" to log in.
struct TestLoginProvider;
impl AdminLoginProvider for TestLoginProvider {
async fn do_login(
&self,
login_data: &quokka_admin::middleware::LoginData,
) -> quokka::Result<Option<LoginResult>> {
if login_data.login_name == "Test" && login_data.password == "Password" {
tracing::debug!("Got a secure login from the test user!");
return Ok(Some(LoginResult {
user_identifier: "Test".to_string(),
}));
}
Ok(None)
}
}
If you want to register a more complex login provider which requires state data, you can simply derive the Clone, FromState traits and
construct it in the configure_state callback by building it there (Quokka does it like this let login_provider = DbLoginProvider:: from_state(state);.
Authentication & Authorization
Authentication and Authorization is, although two separate functions, one trait that has to be implemented AdminAuthProvider. The
separation of these two functions allows multiple modules to work together. So you can have one module that finds the user from one service
(like LDAP) in the authenticate function and validate the user's permission against LDAP or some web API within a second auth provider in
the authorize function. Each function will be called in separate batches on all providers. Here it is the same as with the login. Whatever
provides first response with a value will be taken for granted. So multiple providers checking permissions will always be additive.
Across the providers all the communication should be provider-agnostik, that's also why, by design, the traits do not communicate IDs but strings instead.
The PermissionContext comes with a verb and a resource. Which correspond to the HTTP request Method and the MatchedPath, which in
axum represents the registered URL inside the router, including the path placeholders (eg. /admin/entity/test/{:pks}).
Example
This example with authenticate the "Test" user from the login example above if the name matches and will grant him any permission.
impl<S: Send + Sync + 'static> AdminAuthProvider<S> for TestAuthProvider {
type AuthParams = Extension<Session>;
async fn authenticate(
&self,
params: Self::AuthParams,
) -> quokka::Result<Option<AuthenticatedUser>> {
let login_name = params
.0
.get_extension::<LoginResult>(ADMIN_USER_SESSION_KEY)?
.user_identifier;
if login_name == "Test" {
return Ok(Some(AuthenticatedUser {
name: "Test".to_string(),
groups: Default::default(),
context: Default::default(),
}));
}
Ok(None)
}
async fn authorize(
&self,
user: &AuthenticatedUser,
_: &PermissionContext,
) -> quokka::Result<bool> {
if user.name == "Test" {
return Ok(true);
}
Ok(false)
}
}