Introduction
Auric is an Ember-inspired web application framework for building modern applications in Rust that compiles to WASM. Auric emphasizes the MVC separation of concerns, where:
- Visual Designers maintain an application skin using HTMLbars templates
- Developers maintain controllers, components, models, and adapters that keep code out of templates
An Ember Experience in Rust
Auric provides a way for you to write single-page applications using your favorite Ember design patterns, including Models, Views (templates), Controllers, Components, and Adapters, using type-safe async Rust.
Auric's build process combines those assets into a code-generated Leptos webapp that compiles to WASM for superior performance and security.
Key Technologies Used
Auric integrates the following key technologies:
- HTMLbars (re-imagined in Rust), for offering developers a rich set of expression and data binding functionality within their Handlebars templates.
- Ember Data (re-imagined in Rust), for in-memory model lifecycle management, and for JSON:API based relational storage middleware that makes it possible to implement adapters for REST, JSON:API, GraphQL, gRPC, or anything else.
- Trunk, for WASM bundling, and for supporting development sessions with live page-reload
- Leptos, for rendering and interactivity
Installing
First ensure Rust is installed. Details here.
Next, ensure the wasm32-unknown-unknown Rust platform target is installed so that Rust can compile
to WebAssembly.
$ rustup target add wasm32-unknown-unknown
Next, install the Auric CLI:
$ cargo install auric
Finally, install Trunk:
$ cargo install --locked trunk
You're now ready to create your first Auric application:
$ auric new my-app
==> make
2026-08-13T14:20:39.039679Z INFO 🚀 Starting trunk 0.21.14
2026-08-13T14:20:40.474657Z INFO 📦 starting build
...
Compiling auric-build v0.2.0
Compiling auric-runtime v0.2.0
Compiling handlebars v6.4.4
Compiling js-sys v0.3.103
Compiling console_error_panic_hook v0.1.7
Compiling web-sys v0.3.104
Compiling leptos v0.8.4
Compiling my-app v0.1.0 (/Users/davidr/workspaces/my-app)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 14.25s
$ cd my-app
$ auric serve
==> trunk serve
2026-07-04T14:57:49.311838Z INFO 🚀 Starting trunk 0.21.14
2026-07-04T14:57:49.385672Z INFO 📦 starting build
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.03s
2026-07-04T14:57:49.734082Z INFO applying new distribution
2026-07-04T14:57:49.734841Z INFO ✅ success
2026-07-04T14:57:49.734883Z INFO 📡 serving static assets at -> /
2026-07-04T14:57:49.734931Z INFO 📡 server listening at:
2026-07-04T14:57:49.734935Z INFO 🏠 http://127.0.0.1:4200/
2026-07-04T14:57:49.734937Z INFO 🏠 http://[::1]:4200/
2026-07-04T14:57:49.735070Z INFO 🏠 http://localhost.:4200/
Templates
Templates contain Handlebars markup laying out a snippet of HTML.
The Application Template
Every Auric application has an application template at src/templates/application.hbs, and its
contents are displayed at all times. This template establishes the outer-most structure of
your application layout.
The default content looks like this:
<h1>Welcome to Auric SPA!</h1>
{{outlet}}
When you build your application, a Leptos component is generated for the application template. This
code representation of a template is called the View.
$ make
$ cat src/generated/views/application.rs
// This file is @generated by auric-build.
use auric_runtime::{*, components::*, data::*, routing::*};
use leptos::prelude::*;
use leptos_router::{path, components::*};
use crate::generated::views::{
index::IndexView,
};
#[component]
pub fn ApplicationView() -> impl IntoView {
view! {
<Router>
<h1>Welcome to Auric SPA!</h1>
<div />
<Routes fallback=|| "Not found.">
<Route path=path!("/") view=IndexView />
<Route path=path!("/*any") view=NotFound />
</Routes>
</Router>
}
}
#[component]
fn NotFound() -> impl IntoView {
view! { <h1>"Not found."</h1> }
}
The Index Template
Every Auric application also has an implicit Index route, and associated template containing default
{{outlet}} content. This is required by the Leptos router, which needs an IndexView to associate
with the root route having path="/".
The implicit index template can be replaced with an explicit one, if you need to control the content:
$ auric generate template index
created src/templates/index.hbs
And likewise, the implicit index route can be replaced with an explicit one:
$ auric generate route index
created src/routes/index_route.rs
updated src/routes/mod.rs
created src/templates/index.hbs
Other Templates
Let's define a new route to display a list of customers:
$ auric generate route customers
created src/routes/customers_route.rs
updated src/routes/mod.rs
created src/templates/customers.hbs
==> make
Compiling my-app v0.1.0 (/Users/davidr/workspaces/my-app)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 1.70s
The default content for a new template is simply {{outlet}}. But let's update the template to display a
list of customers:
<h2>Customers:</h2>
{{#each this.model.customers as |customer|}}
<li>{{customer.name}}</li>
{{/each}}
The Route at src/routes/customers.rs would then define a model() function to load the list of customers
from the Store.
When you build your application, the Leptos CustomersView component is generated, and looks like this:
$ make
$ cat src/generated/views/customers.rs
...
#[component]
pub fn CustomersView() -> impl IntoView {
view! {
<h2>Customers:</h2>
...
}
}
Performance and Security
You might have noticed that Handlebars templates are only used at compile-time, and they are not bundled within your web application's wasm bundle, or rendered at runtime. Instead, they are translated at build time into Leptos components, which are compiled and statically linked into your wasm bundle. This design ensures that views are hardened, performant, and async.
Components
A component is like your own custom HTML tag. It provides a way for you to centralize and re-use a snippet of HTML.
Let's generate a component to layout a customer record using a Bootstrap card class:
$ auric generate component customer-card
created src/components/customer_card.hbs
==> make
Compiling ...
Finished `dev` profile [unoptimized + debuginfo] target(s) in 11.91s
The initial content of src/components/customer_card.hbs is simply {{yield}}. Let's customize
it:
<div class="card text-center">
<div class="card-body">
{{yield}}
</div>
</div>
If we rebuild our project, we can see that a Leptos component was generated for our component
at src/generated/components/customer_card.rs:
$ make
Compiling ...
Finished `dev` profile [unoptimized + debuginfo] target(s) in 11.91s
$ cat src/generated/components/customer_card.rs
use leptos::prelude::*;
#[component]
pub fn CustomerCardComponent(children: Children) -> impl IntoView {
view! {
<div class="card text-center">
<div class="card-body">
{children()}
</div>
</div>
}
}
Now let's reference our new customer card component from another view:
{{!-- src/templates/customers.hbs --}}
<CustomerCard>
Acme Widgets
</CustomerCard>
<CustomerCard>
Standard Paper Supplies, Inc.
</CustomerCard>
If we rebuild our project, we can examine the Leptos component that was generated for the customers view, which now contains the references to the customer card component:
$ make
Compiling ...
Finished `dev` profile [unoptimized + debuginfo] target(s) in 11.91s
$ cat src/generated/views/customers.rs
// This file is @generated by auric-build.
use auric_runtime::{*, components::*, data::*, routing::*};
use leptos::prelude::*;
use leptos_route::{path, components::*};
use crate::generated::components::{
customer_card::CustomerCardComponent,
};
#[component]
pub fn CustomersView() -> impl IntoView {
view! {
<CustomerCardComponent>
Acme Widgets
</CustomerCardComponent>
<CustomerCardComponent>
Standard Paper Supplies, Inc.
</CustomerCardComponent>
}
}
Component Arguments and HTML Attributes
Component Arguments
Components can be given named arguments, like this:
{{!-- src/components/customer_card.hbs --}}
<div class="card text-center">
<div class="card-body">
<h5 class="card-title">{{ @name }}</h5>
<p class="card-text">
{{yield}}
</p>
</div>
</div>
And then using the component from a template looks like this:
<CustomerCard @name="Acme Widgets">
We ship to your door.
</CustomerCard>
In the generated code, the datatype of an argument is a JSON Value type, derived from
the calling template. From the example above, @name="Acme Widgets" informs the Auric
code generator that the name argument is a String:
// src/generated/components/customer_card.rs
// This file is @generated by auric-build.
use auric_runtime::components::*;
use leptos::prelude::*;
#[component]
pub fn CustomerCardComponent(
children: Children,
name: String,
) -> impl IntoView {
view! {
<div class="card text-center">
<div class="card-body">
<h5 class="card-title">{name}</h5>
<p class="card-text">
{children()}
</p>
</div>
</div>
}
}
HTML Attributes
When using a component from a template, you can also pass through attributes such as class or anything else,
like this:
<CustomerCard class="text-center" @name="Acme Widgets">
We ship to your door.
</CustomerCard>
And the generated view with the calling code ends up looking like this:
// src/generated/view/customers.rs
// This file is @generated by auric-build.
use auric_runtime::components::*;
use leptos::prelude::*;
#[component]
pub fn CustomersView() -> impl IntoView {
...
view! {
<CustomerCardComponent attr:class="text-center" name="Acme Widgets".to_string()>
"We ship to your door."
</CustomerCardComponent>
}
}
Conditional Content
In HTMLbars templates, you can use if to conditionally render content.
Inline If
The inline-if syntax takes a condition parameter, a valueIfTrue parameter,
and an optional valueIfFalse parameter. Here's an example of the usage:
The car is {{if this.model.isVeryNice 'very' 'not very'}} nice.
What gets generated during a build:
#[component]
pub fn CarView() -> impl IntoView {
...
let signal0 = move || model.get().map(|model| model.get("isVeryNice")).unwrap_or_default();
let signal1 = move || {if (signal0()).as_bool().unwrap_or_default() {"very"} else {"not very"}};
view! {
"The car is " {move || signal1()} "."
}
}
Block If
When you have nested content, use the block-if syntax.
The car is
{{#if this.model.isVeryNice}}
<strong>very nice</strong>
{{else}}
nice
{{/if}}.
What gets generated during a build:
#[component]
pub fn CarView() -> impl IntoView {
...
let signal0 = move || model.get().map(|model| model.get("isVeryNice")).unwrap_or_default();
view! {
"The car is "
<Show when=move || { (&signal0()).as_bool().unwrap_or_default() } fallback=|| view! { "nice" }>
<strong>"very nice"</strong>
</Show>
"."
}
}
Helpers
Helpers are pre-packaged Rust functions that you can call within your HTMLbars expressions.
In the Ember ecosystem, certain helper packages became popular and are commonly used by many apps. These are made available to Auric webapps. The ones you don't use should be stripped out by the Rust compiler.
How They Work
Helpers streamline your development experience by helping to keep your templates clean, highly readable, and maintainable.
Consider this HTMLbars template:
{{!-- src/templates/customer.hbs --}}
<h2>Customer</h2>
<li>Name: {{titleize (trim this.model.name)}}</li>
<li>Address: {{trim this.model.address}}</li>
When you build your Auric project, let's look at the Leptos view that is generated for you, and some of its complexities that you won't have to face directly:
$ make
$ cat src/generated/views/customer.rs
// This file is @generated by auric-build.
use auric_runtime::{*, components::*, data::*, routing::*};
use leptos::prelude::*;
use leptos_router::{path, components::*};
#[component]
pub fn CustomerView() -> impl IntoView {
let maybe_model = LocalResource::new(async move || {
let router = use_context::<Arc<Router>>().expect("Router not found");
let params = move || use_params_map().get();
router.set_params_for("customer", params().into());
router.model_for("customer").await
});
let model = Signal::derive(move || maybe_model.get().and_then(Result::ok));
let model_error = move || maybe_model.get().map(|result| result.map(|_| ()));
let signal0 = move || model.get().map(|model| model.get("name")).unwrap_or_default();
let signal1 = move || auric_runtime::helpers::string::trim(&signal0());
let signal2 = move || auric_runtime::helpers::string::titleize(&signal1());
let signal3 = move || -> String { signal2().into() };
let signal4 = move || model.get().map(|model| model.get("address")).unwrap_or_default();
let signal5 = move || auric_runtime::helpers::string::trim(&signal4());
let signal6 = move || -> String { signal5().into() };
view! {
<h2>"Customer"</h2>
<li>"Name: " {move || signal3()} </li>
<li>"Address: " {move || signal6()} </li>
}
}
ember-cli-string-helpers
The following ember-cli-string-helpers are supported:
camelize, capitalize, classify, dasherize, humanize, lowercase, titleize, trim, truncate, underscore, uppercase, and w.
ember-math-helpers
The following ember-math-helpers are supported:
abs, acos, acosh, add, asin, asinh, atan, atan2, atanh, cbrt, ceil, clz32, cos, cosh, div, exp, expm1, floor, fround, gcd, hypot, imul, lcm, log-e, log1p, log2, log10, max, min, mod, mult, pow, random, round, sign, sin, sqrt, sub, sum, tan, tanh, and trunc.
ember-truth-helpers
The following ember-truth-helpers are supported:
| Helper | Rust Operation | HTMLbars Usage |
|---|---|---|
and | if (a && b) | {{if (and a b)}} |
eq | if (a == b) | {{if (eq a b)}} |
gt | if (a > b) | {{if (gt a b)}} |
gte | if (a >= b) | {{if (gte a b)}} |
is-array | if (serde_json::Value::is_array()) | {{if (is-array a)}} |
is-empty | ...lengthy type-specific logic... | {{if (is-empty a)}} |
is-equal | (identical to eq) | {{if (is-equal a b)}} |
lt | if (a < b) | {{if (lt a b)}} |
lte | if (a <= b) | {{if (lte a b)}} |
not | if (!a) | {{if (not a)}} |
not-eq | if (a != b) | {{if (not-eq a b)}} |
or | if (a || b) | {{if (or a b)}} |
xor | if (a ^ b) | {{if (xor a b)}} |
Routing
A URL can be set in the following ways:
- The user loads the app for the first time.
- The user changes the URL manually, by clicking on a back or forward button, or by editing the address bar.
- The user clicks on a link within the app.
When the URL changes, the Auric framework:
- finds the route handler that you have provided for the given route
- calls its
model()function - renders the route's template, which can include references to the model
Defining Routes
When your application starts, the Leptos router matches the current URL to the routes that you've defined. The routes, in turn, are responsible for loading data, and setting up application state.
Basic Routes
Let's generate a new route:
$ auric generate route invoices
created src/routes/invoices_route.rs
updated src/routes/mod.rs
created src/templates/invoices.hbs
==> make
Compiling my-app v0.1.0 (/Users/davidr/workspaces/my-app)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 1.73s
This will generate the following file:
// src/routes/invoices_route.rs
use anyhow::Result;
use auric_runtime::async_trait::async_trait;
use auric_runtime::routing::Route;
use auric_runtime::{AuricRoute, Context};
use serde_json::{Value, json};
#[derive(AuricRoute, Default)]
#[route("/invoices")]
pub struct InvoicesRoute {}
#[async_trait]
impl Route for InvoicesRoute {
fn init(&mut self, _context: &dyn Context) -> Result<()> {
Ok(())
}
async fn model(&self, _params: RouteParams) -> Result<RouteModel> {
Ok(json!({}).into())
}
}
#[cfg(test)]
mod tests {
use super::InvoicesRoute;
#[test]
fn can_construct() {
let _ = InvoicesRoute::default();
}
}
The Auric build process also generates an ApplicationView that includes the Leptos Router and its
Route definitions. Let's have a look:
// src/generated/views/application.rs
...
#[component]
pub fn ApplicationView() -> impl IntoView {
view! {
<Router>
<Routes fallback=|| "Not found.">
<Route path=path!("/") view=IndexView />
<Route path=path!("/invoices") view=InvoicesView />
<Route path=path!("/*any") view=|| view! { <h1>Not found.</h1> } />
</Routes>
<h1>Welcome to Auric SPA!</h1>
<div />
</Router>
}
}
Nested Routes
Nested routes are used when you want to have a template that displays within another template. They are automatically generated for you when you create a hierarchy of routes such as this:
$ auric generate route customers/new
created src/routes/customers/new_route.rs
updated src/routes/customers/mod.rs
updated src/routes/mod.rs
created src/templates/customers/new.hbs
==> make
Compiling auric-runtime v0.1.8 (/Users/davidr/workspaces/auric-spa/auric/crates/auric-runtime)
Compiling my-app v0.1.0 (/Users/davidr/workspaces/my-app)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 2.93s
Examine the generated application view to see the nested route definitions:
$ cat src/generated/views/application.rs
...
#[component]
pub fn ApplicationView() -> impl IntoView {
...
view! {
<Router>
<Routes fallback=|| "Not found.">
<Route path=path!("/") view=IndexView />
<ParentRoute path=path!("/customers") view=CustomersView>
<Route path=path!("/new") view=CustomersNewView />
</ParentRoute>
<Route path=path!("/*any") view=|| view! { <h1>Not found.</h1> } />
</Routes>
...
</Router>
}
}
Index Routes
At every level of nesting, Auric automatically provides an implicit route for the index path.
Implicit routes use the pre-defined view=DefaultView from auric_runtime::components.
You can examine the generated application view for the details:
$ cat src/generated/views/application.rs
...
#[component]
pub fn ApplicationView() -> impl IntoView {
...
view! {
<Router>
<h1>"Welcome to Auric SPA!"</h1>
<div />
<Routes fallback=|| "Not found.">
<Route path=path!("/") view=IndexView />
<Route path=path!("/customer/:customer_id") view=CustomerView />
<ParentRoute path=path!("/customers") view=CustomersView>
<Route path=path!("") view=DefaultView /> <== IMPLICIT ROUTE
<Route path=path!("/new") view=CustomersNewView />
</ParentRoute>
<Route path=path!("/*any") view=|| view! { <h1>Not found.</h1> } />
</Routes>
</Router>
}
}
You can also define an explicit index route, to control the model() function or for other purposes:
$ auric generate route customers/index
created src/routes/customers/index_route.rs
created src/templates/customers/index.hbs
==> make
Compiling auric-build v0.2.0 (/Users/davidr/workspaces/auric-spa/auric/crates/auric-build)
Compiling auric-runtime v0.2.0 (/Users/davidr/workspaces/auric-spa/auric/crates/auric-runtime)
Compiling my-app v0.1.0 (/Users/davidr/workspaces/my-app)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 3.29s
$ cat src/generated/views/application.rs
...
#[component]
pub fn ApplicationView() -> impl IntoView {
...
view! {
<Router>
<h1>"Welcome to Auric SPA!"</h1>
<div />
<Routes fallback=|| "Not found.">
<Route path=path!("/") view=IndexView />
<Route path=path!("/customer/:customer_id") view=CustomerView />
<ParentRoute path=path!("/customers") view=CustomersView>
<Route path=path!("") view=CustomersIndexView /> <== EXPLICIT ROUTE
<Route path=path!("/new") view=CustomersNewView />
</ParentRoute>
<Route path=path!("/*any") view=|| view! { <h1>Not found.</h1> } />
</Routes>
</Router>
}
}
Dynamic Segments
One of the responsibilities of a route handler is to load a model. The route handler struct
needs to define the necessary URL parameter within the #[route] attribute macro.
This example shows a route that loads a customer record:
// src/routes/customer_route.rs
use crate::generated::Bucket;
use crate::models::customer::Customer;
use anyhow::Result;
use auric_runtime::data::Store;
use auric_runtime::routing::{Route, RouteModel, RouteParams};
use auric_runtime::{AuricRoute, Context, async_trait};
use jsonapi_core::Resource;
use std::sync::Arc;
#[derive(AuricRoute, Default)]
#[route("/customer/:customer_id")]
pub struct CustomerRoute {
store: Arc<Store>,
}
#[async_trait(?Send)]
impl Route for CustomerRoute {
fn init(&mut self, context: &dyn Context) -> Result<()> {
self.store = context.store();
Ok(())
}
async fn model(&self, params: RouteParams) -> Result<RouteModel> {
let id = params.get("customer_id").expect("Expected customer_id in params");
let customer: Customer = self.store.find_record(Bucket::Customers, id, None).await?;
Ok(Resource::from_typed(&customer)?.into())
}
}
Linking Between Routes
Establishing links to different parts of your application can be done with the LinkTo component.
It takes a @route parameter, which specifies the dot-delimited name of the route to link to.
Let's link to an invoices page from a navbar in our application template:
// src/templates/application.hbs
<h1>Links:</h1>
<LinkTo @route="customers">
<button class="btn btn-default">Customers</button>
</LinkTo>
<LinkTo @route="invoices">
<button class="btn btn-default">Invoices</button>
</LinkTo>
{{outlet}}
This will generate the following file:
// src/generated/application.rs
...
#[component]
pub fn ApplicationView() -> impl IntoView {
view! {
<Router>
<Routes fallback=|| "Not found.">
...
</Routes>
<h1>Links:</h1>
<LinkToComponent path="customers".to_string()>
<button class="btn btn-default">Customers</button>
</LinkToComponent>
<LinkToComponent path="invoices".to_string()>
<button class="btn btn-default">Invoices</button>
</LinkToComponent>
<div />
</Router>
}
}
Dynamic Segments
The LinkTo component also supports a @model parameter, which is required to navigate
to a route with a dynamic segment, such as /customer/123.
<LinkTo @route="customer" @model="123">
<button class="btn btn-default">Customer 123</button>
</LinkTo>
You can also use a HTMLbars expression:
<LinkTo @route="customer" @model="{{ this.model.id }}">
<button class="btn btn-default">Customer 123</button>
</LinkTo>
NOTE:
The HTMLbars expression within the mustache requires double-quotes around it, whereas in Ember the quotes are not required. I hope this inconvenience is temporary.
Specifying a Route's Model
You define a route's model by implementing its model() function.
Static Models
Let's return a simple model:
// src/routes/customers_route.rs
...
#[async_trait]
impl Route for CustomersRoute {
...
async fn model(&self, _params: RouteParams) -> Result<RouteModel> {
Ok(json!({
"name": "Acme Widgets",
"totalMonthlySpend": 2300,
}).into())
}
}
You can now reference the model values from your route template:
{{!-- src/templates/customers.hbs --}}
<li>Name: {{this.model.name}}</li>
<li>Total Monthly Spend: {{this.model.totalMonthlySpend}}</li>
Fetching Data
To fetch data from your model() function, first customize your route initialization to
save a reference to the application-wide Store:
// src/routes/customers_route.rs
#[derive(AuricRoute, Default)]
#[route("/customers")]
pub struct CustomersRoute {
store: Arc<Store>,
}
#[async_trait]
impl Route for CustomersRoute {
fn init(&mut self, context: &dyn Context) -> Result<()> {
self.store = context.store();
Ok(())
}
}
Now you can use the store from your model() function:
// src/routes/customers_route.rs
use crate::generated::Bucket;
#[async_trait]
impl Route for CustomersRoute {
...
async fn model(&self, _params: RouteParams) -> Result<RouteModel> {
let customers: Vec<Customer> = self.store.find_all(Bucket::Customers, None).await?;
Ok(RouteModel::from(customers))
}
}
See Routing / Defining Routes / Dynamic Segments
for an example model() function that returns a single record using an id URL parameter.
Controllers
A controller handles the business logic for a given route. If you have a customers/new route that
needs custom logic such as an event handler, you'll want to create a customers/new controller to
hold the event handling functions as well as any potential tracked values.
Defining a Controller
$ auric generate controller customers/new
created src/controllers/customers/new_controller.rs
updated src/controllers/customers/mod.rs
updated src/controllers/mod.rs
==> make
Compiling auric-build v0.2.2 (/Users/davidr/workspaces/auric-spa/auric/crates/auric-build)
Compiling auric-derive v0.2.2 (/Users/davidr/workspaces/auric-spa/auric/crates/auric-derive)
Compiling auric-runtime v0.2.2 (/Users/davidr/workspaces/auric-spa/auric/crates/auric-runtime)
Compiling my-app v0.1.0 (/Users/davidr/workspaces/my-app)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 4.30s
Here's what the new controller looks like in its initial state:
use anyhow::Result;
use auric_runtime::{AuricController, Context, Controller, async_trait, controller};
#[derive(AuricController, Default)]
pub struct CustomersNewController {}
#[async_trait(?Send)]
impl Controller for CustomersNewController {
fn init(&mut self, _context: &dyn Context) -> Result<()> {
Ok(())
}
}
#[controller]
impl CustomersNewController {}
#[cfg(test)]
mod tests {
use super::CustomersNewController;
#[test]
fn can_construct() {
let _ = CustomersNewController::default();
}
}
Adding a tracked value
To add a tracked value, add it to your controller struct, and decorate it with the #[tracked] derive macro,
like this:
#[derive(AuricController, Default)]
pub struct CustomersNewController {
#[tracked]
counter: u16,
}
When you build your project, a corresponding Leptos signal will be generated into the view that corresponds with your controller:
// src/generated/views/customers_new.rs
let (counter, set_counter) = signal::<u16>(Default::default()); // tracked from controller
view! {
...
Handling Events
This example shows how to write an event handler to increment a tracked property, then display the latest value.
Let's start with the template:
<h2>Counter Example</h2>
Counter: {{this.counter}}
<button class="btn btn-default" {{on "click" this.increment}}>
Increment
</button>
Next, we need to add an event handler function to our controller:
#[controller]
impl CustomersNewController {
pub fn increment(&mut self) {
self.counter += 1;
}
}
That's it! Let's look at the Leptos view that was generated:
// This file is @generated by auric-build.
use auric_runtime::{*, components::*, routing::*};
use leptos::prelude::*;
use leptos_router::{components::*, hooks::use_params_map};
use std::sync::Arc;
#[component]
pub fn CustomersNewController() -> impl IntoView {
let config = use_context::<serde_json::Value>().expect("Config not found");
let store = use_context::<Arc<auric_runtime::data::Store>>().expect("Store not found");
let ctx = InitContext::new(config.clone(), store.clone());
let mut controller = crate::controllers::customers::new_controller::CustomersNewController::default();
controller.init(&ctx).expect("failed initializing controller");
let maybe_model = LocalResource::new(async move || {
let router = use_context::<Arc<Router>>().expect("Router not found");
let params = move || use_params_map().get();
router.set_params_for("customers.new", params().into());
router.model_for("customers.new").await
});
let model = Signal::derive(move || maybe_model.get().and_then(Result::ok));
let model_error = move || maybe_model.get().map(|result| result.map(|_| ()));
let (counter, set_counter) = signal::<u16>(Default::default()); // tracked from controller
view! {
<h2>"Counter Example"</h2>
"Counter: "
{move || counter.get()}
<button
class="btn btn-default"
on:click=move |_| {
controller.set_counter(&counter);
controller.increment();
set_counter.set(*controller.get_counter());
}
>
"Increment"
</button>
}
}
Controllers provide the means by which you can keep your templates lean and manageable, thanks to the power of HTMLbars.
With Auric, also notice that the path references within mustache expressions, such as
the {{this.counter}} tracked variable reference or the {{on "click" this.increment}} event handler function,
are statically bound to real Rust variables and functions at compile time.
Any mustache reference to a non-existent field, such as {{this.doesNotExist}}, is a compile error.
Auric leverages the Rust compiler to fully validate your templates.
Data Framework
Auric has a data framework that is equivalent to Ember Data, enabling you to define Models that work with a back-end through adapters for REST, JSON:API, GraphQL, gRPC, or anything else.
Defining the Application Adapter
The first step in setting up data access is to generate an Application adapter. This establishes a default adapter for the Store to use, which can be supplemented later with additional model-specific adapters.
Let's generate a REST-based Application adapter:
$ auric generate adapter application --target rest
created src/adapters/application.rs
updated src/adapters/mod.rs
==> make
Compiling my-app v0.1.0 (/Users/davidr/workspaces/my-app)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 2.47s
This will create the following file:
// src/adapters/application.rs
use auric_runtime::{
Config,
async_trait::async_trait,
data::{Adapter, QueryRecordOptions, adapters::RestAdapter},
};
use jsonapi_core::Resource;
#[derive(Default)]
pub struct ApplicationAdapter {
target: RestAdapter,
}
#[async_trait(?Send)]
impl Adapter for ApplicationAdapter {
fn init(&mut self, config: &Config) -> Result<()> {
self.target.init(config)
}
async fn create_record(&self, resource: Resource) -> Result<Resource> {
self.target.create_record(resource).await
}
async fn delete_record(&self, resource: &Resource) -> Result<()> {
self.target.delete_record(resource).await
}
async fn find_record(&self, resource_type: &str, id: &str) -> Result<Resource> {
self.target.find_record(resource_type, id).await
}
async fn query(&self, resource_type: &str, query: serde_json::Value) -> Result<Vec<Resource>> {
self.target.query(resource_type, query).await
}
async fn query_record(
&self,
resource_type: &str,
query: serde_json::Value,
options: QueryRecordOptions,
) -> Result<Option<Resource>> {
self.target
.query_record(resource_type, query, options)
.await
}
async fn update_record(&self, resource: Resource) -> Result<Resource> {
self.target.update_record(resource).await
}
}
#[cfg(test)]
mod tests {
use super::ApplicationAdapter;
use auric_runtime::data::Adapter;
use serde_json::json;
#[test]
fn can_construct() {
let _ = ApplicationAdapter::default();
}
#[test]
fn can_init() {
let mut adapter = ApplicationAdapter::default();
let config = json!({"api_url": "http://localhost:3000"});
adapter
.init(&config)
.expect("Expected to initialize with a valid api url");
}
#[test]
fn cannot_init_with_missing_api_url() {
let mut adapter = ApplicationAdapter::default();
let config = json!({"someStuff": "abc"});
assert!(
adapter.init(&config).is_err(),
"Expected init to fail due to missing api url",
);
}
}
As you can see, the implementation of each Adapter trait function simply delegates to a target RestAdapter, providing a default implementation upfront, while leaving you in a position to make future customizations.
There is also a JsonApiAdapter, that you can target with:
$ auric generate adapter application --target json-api
Defining Models
A model struct defines the properties and behavior of the data presented to your user.
Generating a Model
Let's generate a new model:
$ auric generate model customer
created src/models/customer.rs
updated src/models/mod.rs
==> make
Compiling my-app v0.1.0 (/Users/davidr/workspaces/my-app)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 2.47s
This will generate the following file:
// src/models/customer.rs
use auric_runtime::AuricModel;
use jsonapi_core::JsonApi;
#[derive(AuricModel, Clone, Debug, Default, JsonApi, PartialEq)]
#[jsonapi(type = "customers")]
pub struct Customer {
#[jsonapi(id)]
pub id: String,
}
#[cfg(test)]
mod tests {
use super::Customer;
#[test]
fn can_construct() {
let _ = Customer::default();
}
}
The full range of model features are documented in the jsonapi_core crate.
Adding Fields and Relations
Let's add a few additional fields:
// src/models/customer.rs
use auric_runtime::AuricModel;
use jsonapi_core::{JsonApi, Relationship};
use super::account::Account;
#[derive(AuricModel, Clone, Debug, Default, JsonApi, PartialEq)]
#[jsonapi(type = "customers")]
pub struct Customer {
#[jsonapi(id)]
pub id: String,
pub name: String,
#[jsonapi(relationship, type = "accounts")]
pub owner: Relationship<Account>,
}
The Bucket Enum
A Bucket enum is generated at src/generated/bucket.rs during the build process, so that the Rust
compiler can validate your resource type, and so that you do not have to worry about when to singularize, pluralize,
dasherize, or camelize it.
// This file is @generated by auric-build.
use strum_macros::AsRefStr;
#[derive(AsRefStr, Clone, Debug)]
pub enum Bucket {
#[strum(serialize = "customers")]
Customers,
#[strum(serialize = "invoiceLineItems")]
InvoiceLineItems,
}
let customer: Customer = store.find_record(Bucket::Customers, "123", None).await?;
Finding Records
The Store provides functions for retrieving records of a single type.
See Fetching Data in the routing guide for how to obtain a reference to the application-wide store.
Retrieving a Single Record
Use Store::find_record() to retrieve a record by its type and ID.
use crate::generated::Bucket;
use crate::models::customer::Customer;
let customer: Customer = store.find_record(Bucket::Customers, "123", None).await?;
Use Store::peek_record() to retrieve a record by its type and ID, without making
a network request. This will return the record only if it is already present in the store's cache:
let maybe_customer: Option<Customer> = store.peek_record(Bucket::Customers, "123")?;
Retrieving Multiple Records
Use Store::find_all() to retrieve all of the records for a given type:
let customers: Vec<Customer> = store.find_all(Bucket::Customers, None).await?;
Use Store::peek_all() to retrieve all of the records for a given type that are already
loaded in the store, without making a network request:
let customers: Vec<Customer> = store.peek_all(Bucket::Customers)?;
Querying for Multiple Records
Use Store::query() to query for records that meet certain criteria.
use serde_json::json;
let customers: Vec<Customer> = store.query(Bucket::Customers, json!({
"filter": {
"name": "Acme Widgets",
},
})).await?;
Creating, Updating, and Deleting Records
See Fetching Data
in the routing guide for how to obtain a reference to the application-wide Store.
Creating Records
Use Store::create_record() or Store::save() to create a record.
use crate::generated::Bucket;
use crate::models::customer::Customer;
let customer = Customer{
name: "Acme Widgets".to_string(),
..Default::default()
};
let finalized_customer = store.create_record(Bucket::Customers, &customer).await?;
log::info!("The assigned customer id is {}", finalized_customer.id);
Updating Records
Use Store::save() to store an updated record.
let mut customer: Customer = store.find_record(Bucket::Customers, "123", None).await?;
customer.name = "New Name".to_string();
let customer = store.save(&customer).await?;
Deleting Records
Use Store::delete_record() to delete a record:
let customer: Customer = store.find_record(Bucket::Customers, "123", None).await?;
store.delete_record(&customer).await?;
Pushing Records into the Store
The Store provides a cache of all the previously loaded records. This enables dynamic routing, including back-navigation, to see a consistent set of records, whether dirty or not, which has always been one of Ember's core strengths.
Records can be pushed into the cache ahead of time, to avoid later network operations.
See Fetching Data
in the routing guide for how to obtain a reference to the application-wide Store.
Pushing Records
Use Store::push() to push a record into the store.
use crate::models::customer::Customer;
let customer = Customer{
id: "123".to_string(),
name: "Acme Widgets".to_string(),
..Default::default()
};
store.push(customer)?;
Application Concerns
Configuring your Application
Configuration is performed within the src/config.rs file, which defines a configuration factory
function that returns a JSON object.
To customize your application build, use the env! and option_env! Rust macros to read environment
variables at build time. This will hard-code their final values into your sealed wasm bundle, ensuring
scrict release versioning and optimal security.
Configuring your Adapter
One common use case is the need to configure your application's adapters with the URL of your back-end API service. To achieve that, your configuration might look like this:
// src/config.rs
pub fn new() -> serde_json::Value {
serde_json::json!({
"api_url": option_env!("API_URL").unwrap_or("http://localhost:3000"),
})
}
or
// src/config.rs
pub fn new() -> serde_json::Value {
serde_json::json!({
"api_url": env!("API_URL").expect("an API_URL is required to build"),
})
}
At build time, you would provide the API URL during calls to make as follows for a test environment:
$ API_URL=https://api.test.yourapp.yourdomain make
or for a production environment:
$ API_URL=https://api.yourapp.yourdomain make
On application startup, adapters have their init() function invoked, which is where the config is passed
in as a parameter.
Logging
When the Auric CLI generates your app skeleton with the init or new subcommand, the
following default code is added into your src/main.rs which initializes the console_log
crate:
fn main() {
console_error_panic_hook::set_once();
auric_runtime::console_log::init_with_level(log::Level::Debug)
.expect("failed initializing logging");
leptos::mount::mount_to_body(generated::views::application::ApplicationView);
}
So to log to the console from anywhere in your app, use console_log macros
such as log::debug! or log::info! like this:
#[derive(AuricController, Default)]
pub struct MyController {}
#[async_trait(?Send)]
impl Controller for MyController {
fn init(&mut self, _context: &dyn Context) -> Result<()> {
let x = 5;
log::debug!("MyController initialized with x={x}");
Ok(())
}
}