Views

In Loco, the processing of web requests is divided between a controller, model and view.

  • The controller is handling requests parsing payload, and then control flows to models
  • The model primarily deals with communicating with the database and executing CRUD operations when required. As well as modeling all business and domain logic and operations.
  • The view takes on the responsibility of assembling and rendering the final response to be sent back to the client.

You can choose to have JSON views, which are JSON responses, or Template views which are powered by a template view engine and eventually are HTML responses. You can also combine both.

This is similar in spirit to Rails' `jbuilder` views which are JSON, and regular views, which are HTML, only that in LOCO we focus on being JSON-first.

JSON views

As an example we have an endpoint that handles user login. When the user is valid we can pass the user model into the LoginResponse view (which is a JSON view) to return the response.

There are 3 steps:

  1. Parse, accept the request
  2. Create domain objects: models
  3. Hand off the domain model to a view object which shapes the final response

The following Rust code represents a controller responsible for handling user login requests, which handes off shaping of the response to LoginResponse.

use crate::{views::auth::LoginResponse};
async fn login(
    State(ctx): State<AppContext>,
    Json(params): Json<LoginParams>,
) -> Result<Json<LoginResponse>> {
    // Fetching the user model with the requested parameters
    // let user = users::Model::find_by_email(&ctx.db, &params.email).await?;

    // Formatting the JSON response using LoginResponse view
    format::json(LoginResponse::new(&user, &token))
}

On the other hand, LoginResponse is a response shaping view, which is powered by serde:

use serde::{Deserialize, Serialize};

use crate::models::_entities::users;

#[derive(Debug, Deserialize, Serialize)]
pub struct LoginResponse {
    pub token: String,
    pub pid: String,
    pub name: String,
}

impl LoginResponse {
    #[must_use]
    pub fn new(user: &users::Model, token: &String) -> Self {
        Self {
            token: token.to_string(),
            pid: user.pid.to_string(),
            name: user.name.clone(),
        }
    }
}

Template views

When you want to return HTML to the user, you use server-side templates. This is similar to how Ruby's erb works, or Node's ejs, or PHP for that matter.

For server-side templates rendering we provide the built in TeraView engine which is based on the popular Tera template engine.

To use this engine you need to verify that you have a ViewEngineInitializer in initializers/view_engine.rs which is also specified in your app.rs. If you used the SaaS Starter, this should already be configured for you.

The Tera view engine takes resources from the new assets/ folder. Here is an example structure:

assets/
├── i18n
│   ├── de-DE
│   │   └── main.ftl
│   ├── en-US
│   │   └── main.ftl
│   └── shared.ftl
├── static
│   ├── 404.html
│   └── image.png
└── views
    └── home
        └── hello.html
config/
:
src/
├── controllers/
├── models/
:
└── views/

Creating a new view

First, create a template. In this case we add a Tera template, in assets/views/home/hello.html. Note that assets/ sits in the root of your project (next to src/ and config/).

<html><body>
find this tera template at <code>assets/views/home/hello.html</code>: 
<br/>
<br/>
{{ /* t(key="hello-world", lang="en-US") */ }}, 
<br/>
{{ /* t(key="hello-world", lang="de-DE") */ }}

</body></html>

Now create a strongly typed view to encapsulate this template in src/views/dashboard.rs:

// src/views/dashboard.rs
pub fn home(v: impl ViewRenderer) -> Result<impl IntoResponse> {
    format::render().view(&v, "home/hello.html", json!({}))
}

And add it to src/views/mod.rs:

pub mod dashboard;

Finally, go to your controller and use the view:

// src/controllers/dashboard.rs
pub async fn render_home(ViewEngine(v): ViewEngine<TeraView>) -> Result<impl IntoResponse> {
    views::dashboard::home(v)
}

How does it work?

  • ViewEngine is an extractor that's available to you via loco_rs::prelude::*
  • TeraView is the Tera view engine that we supply with Loco also available via loco_rs::prelude::*
  • Controllers need to deal with getting a request, calling some model logic, and then supplying a view with models and other data, not caring about how the view does its thing
  • views::dashboard::home is an opaque call, it hides the details of how a view works, or how the bytes find their way into a browser, which is a Good Thing
  • Should you ever want to swap a view engine, the encapsulation here works like magic. You can change the extractor type: ViewEngine<Foobar> and everything works, because v is eventually just a ViewRenderer trait

Static assets

If you want to serve static assets and reference those in your view templates, you can use the Static Middleware, configure it this way:

static:
  enable: true
  must_exist: true
  precompressed: false
  folder:
    uri: "/static"
    path: "assets/static"
  fallback: "assets/static/404.html"

In your templates you can refer to static resources in this way:

<img src="/static/image.png"/>

Customizing the Tera view engine

The Tera view engine comes with the following configuration:

  • Template loading and location: assets/**/*.html
  • Internationalization (i18n) configured into the Tera view engine, you get the translation function: t(..) to use in your templates

If you want to change any configuration detail for the i18n library, you can go and edit src/initializers/view_engine.rs.

By editing the initializer you can:

  • Add custom Tera functions
  • Remove the i18n library
  • Change configuration for Tera or the i18n library
  • Provide a new or custom, Tera (maybe a different version) instance

Using your own view engine

If you do not like Tera as a view engine, or want to use Handlebars, or others you can create your own custom view engine very easily.

Here's an example for a dummy "Hello" view engine. It's a view engine that always returns the word hello.

// src/initializers/hello_view_engine.rs
use axum::{async_trait, Extension, Router as AxumRouter};
use loco_rs::{
    app::{AppContext, Initializer},
    controller::views::{ViewEngine, ViewRenderer},
    Result,
};
use serde::Serialize;

#[derive(Clone)]
pub struct HelloView;
impl ViewRenderer for HelloView {
    fn render<S: Serialize>(&self, _key: &str, _data: S) -> Result<String> {
        Ok("hello".to_string())
    }
}

pub struct HelloViewEngineInitializer;
#[async_trait]
impl Initializer for HelloViewEngineInitializer {
    fn name(&self) -> String {
        "custom-view-engine".to_string()
    }

    async fn after_routes(&self, router: AxumRouter, _ctx: &AppContext) -> Result<AxumRouter> {
        Ok(router.layer(Extension(ViewEngine::from(HelloView))))
    }
}

To use it, you need to add it to your src/app.rs hooks:

// src/app.rs
// add your custom "hello" view engine in the `initializers(..)` hook
impl Hooks for App {
    // ...
    async fn initializers(_ctx: &AppContext) -> Result<Vec<Box<dyn Initializer>>> {
        Ok(vec![
            // ,.----- add it here
            Box::new(initializers::hello_view_engine::HelloViewEngineInitializer),
        ])
    }
    // ...