The Loco Guide

Guide Assumptions

This is a "long way round" tutorial. It is long and indepth on purpose, it shows you how to build things manually and automatically using generators, so that you learn the skills to build and also how things work.

What's with the name?

The name Loco comes from locomotive, as a tribute to Rails, and loco is easier to type than locomotive :-). Also, in some languages it means "crazy" but that was not the original intention (or, is it crazy to build a Rails on Rust? only time will tell!).

How much Rust do I need to know?

You need to be familiar with Rust to a beginner but not more than moderate-beginner level. You need to know how to build, test, and run Rust projects, have used some popular libraries such as clap, regex, tokio, axum or other web framework, nothing too fancy. There are no crazy lifetime twisters or complex / too magical, macros in Loco that you need to know how they work.

What is Loco?

Loco is strongly inspired by Rails. If you know Rails and Rust, you'll feel at home. If you only know Rails and new to Rust, you'll find Loco refreshing. We do not assume you know Rails.

We think Rails is so great, that this guide is strongly inspired from the Rails guide, too

Loco is a Web or API framework for Rust. It's also a productivity suite for developers: it contains everything you need while building a hobby or your next startup. It's also strongly inspired by Rails.

  • You have a variant of the MVC model, which removes the paradox of option. You deal with building your app, not making academic decisions for what abstractions to use.
  • Fat models, slim controllers. Models should contain most of your logic and business implementation, controllers should just be a lightweight router that understands HTTP and moves parameters around.
  • Command line driven to keep your momentum and flow. Generate stuff over copying and pasting or coding from scratch.
  • Every task is "infrastructure-ready", just plug in your code and wire it in: controllers, models, views, tasks, background jobs, mailers, and more.
  • Convention over configuration: decisions are already done for you -- the folder structure matter, configuration shape and values matter, and the way an app is wired matter to how an app operates and for you do be the most effective.

Creating a New Loco App

You can follow this guide for a step-by-step "bottom up" learning, or you can jump and go with the tour instead for a quicker "top down" intro.

Installing

cargo install loco
cargo install sea-orm-cli # Only when DB is needed

Creating a new Loco app

Now you can create your new app (choose "SaaS app" for built-in authentication).

 loco new
 ❯ App name? · myapp
 ❯ What would you like to build? · Saas App with client side rendering
 ❯ Select a DB Provider · Sqlite
 ❯ Select your background worker type · Async (in-process tokio async tasks)

🚂 Loco app generated successfully in:
myapp/

- assets: You've selected `clientside` for your asset serving configuration.

Next step, build your frontend:
  $ cd frontend/
  $ npm install && npm run build

Here's a rundown of what Loco creates for you by default:

File/FolderPurpose
src/Contains controllers, models, views, tasks and more
app.rsMain component registration point. Wire the important bits here.
lib.rsVarious rust-specific exports of your components.
bin/Has your main.rs file, you don't need to worry about it
controllers/Contains controllers, all controllers are exported via mod.rs
models/Contains models, models/_entities contains auto-generated SeaORM models, and models/*.rs contains your model extension logic, which are exported via mod.rs
views/Contains JSON-based views. Structs which can serde and output as JSON through the API.
workers/Has your background workers.
mailers/Mailer logic and templates, for sending emails.
fixtures/Contains data and automatic fixture loading logic.
tasks/Contains your day to day business-oriented tasks such as sending emails, producing business reports, db maintenance, etc.
tests/Your app-wide tests: models, requests, etc.
config/A stage-based configuration folder: development, test, production

Hello, Loco!

Let's get some responses quickly. For this, we need to start up the server.

You can now switch to to myapp:

$ cd myapp

Starting the server

cargo loco start

And now, let's see that it's alive:

$ curl localhost:5150/_ping
{"ok":true}

The built in _ping route will tell your load balancer everything is up.

Let's see that all services that are required are up:

$ curl localhost:5150/_health
{"ok":true}
The built in _health route will tell you that you have configured your app properly: it can establish a connection to your Database and Redis instances successfully.

Say "Hello", Loco

Let's add a quick hello response to our service.

$ cargo loco generate controller guide --api
added: "src/controllers/guide.rs"
injected: "src/controllers/mod.rs"
injected: "src/app.rs"
added: "tests/requests/guide.rs"
injected: "tests/requests/mod.rs"

This is the generated controller body:

#![allow(clippy::missing_errors_doc)]
#![allow(clippy::unnecessary_struct_initialization)]
#![allow(clippy::unused_async)]
use loco_rs::prelude::*;
use axum::debug_handler;

#[debug_handler]
pub async fn index(State(_ctx): State<AppContext>) -> Result<Response> {
    format::empty()
}

pub fn routes() -> Routes {
    Routes::new()
        .prefix("api/guides/")
        .add("/", get(index))
}

Change the index handler body:

// replace
    format::empty()
// with this
    format::text("hello")

Start the server:

cargo loco start

Now, let's test it out:

$ curl localhost:5150/api/guides
hello

Loco has powerful generators, which will make you 10x productive and drive your momentum when building apps.

If you'd like to be entertained for a moment, let's "learn the hard way" and add a new controller manually as well.

Add a file called home.rs, and pub mod home; it in mod.rs:

src/
  controllers/
    auth.rs
    home.rs      <--- add this file
    users.rs
    mod.rs       <--- 'pub mod home;' the module here

Next, set up a hello route, this is the contents of home.rs:

// src/controllers/home.rs
use loco_rs::prelude::*;

// _ctx contains your database connection, as well as other app resource that you'll need
async fn hello(State(_ctx): State<AppContext>) -> Result<Response> {
    format::text("ola, mundo")
}

pub fn routes() -> Routes {
    Routes::new().prefix("home").add("/hello", get(hello))
}

Finally, register this new controller routes in app.rs:

src/
  controllers/
  models/
  ..
  app.rs   <---- look here

Add the following in routes():

// in src/app.rs
#[async_trait]
impl Hooks for App {
    ..
    fn routes() -> AppRoutes {
        AppRoutes::with_default_routes()
            .add_route(controllers::guide::routes())
            .add_route(controllers::auth::routes())
            .add_route(controllers::home::routes()) // <--- add this
    }

That's it. Kill the server and bring it up again:

cargo loco start

And hit /home/hello:

$ curl localhost:5150/home/hello
ola, mundo

You can take a look at all of your routes with:

$ cargo loco routes
  ..
  ..
[POST] /api/auth/login
[POST] /api/auth/register
[POST] /api/auth/reset
[POST] /api/auth/verify
[GET] /home/hello      <---- this is our new route!
  ..
  ..
$
The SaaS Starter keeps routes under /api because it is client-side ready and we are using the --api option in scaffolding.
When using client-side routing like React Router, we want to separate backend routes from client routes: the browser will use /home but not /api/home which is the backend route, and you can call /api/home from the client with no worries. Nevertheless, the routes: /_health and /_ping are exceptions, they stay at the root.

MVC and You

Traditional MVC (Model-View-Controller) originated in desktop UI programming paradigms. However, its applicability to web services led to its rapid adoption. MVC's golden era was around the early 2010s, and since then, many other paradigms and architectures have emerged.

MVC is still a very strong principle and architecture to follow for simplifying projects, and this is what Loco follows too.

Although web services and APIs don't have a concept of a view because they do not generate HTML or UI responses, we claim stable, safe services and APIs indeed has a notion of a view -- and that is the serialized data, its shape, its compatibility and its version.

// a typical loco app contains all parts of MVC

src/
  controllers/
    users.rs
    mod.rs
  models/
    _entities/
      users.rs
      mod.rs
    users.rs
    mod.rs
  views/
    users.rs
    mod.rs

This is an important cognitive principle. And the principle claims that you can only create safe, compatible API responses if you treat those as a separate, independently governed thing -- hence the 'V' in MVC, in Loco.

Models in Loco carry the same semantics as in Rails: fat models, slim controllers. This means that every time you want to build something -- you reach out to a model.

Generating a model

A model in Loco represents data and functionality. Typically the data is stored in your database. Most, if not all, business processes of your applications would be coded on the model (as an Active Record) or as an orchestration of a few models.

Let's create a new model called Article:

$ cargo loco generate model article title:string content:text

added: "migration/src/m20231202_173012_articles.rs"
injected: "migration/src/lib.rs"
injected: "migration/src/lib.rs"
added: "tests/models/articles.rs"
injected: "tests/models/mod.rs"

Database migrations

Keeping your schema honest is done with migrations. A migration is a singular change to your database structure: it can contain complete table additions, modifications, or index creation.

// this was generated into `migrations/` from the command:
//
// $ cargo loco generate model article title:string content:text
//
// it is automatically applied by Loco's migrator framework.
// you can also apply it manually using the command:
//
// $ cargo loco db migrate
//
#[async_trait::async_trait]
impl MigrationTrait for Migration {
    async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
        manager
            .create_table(
                table_auto_tz(Articles::Table)
                    .col(pk_auto(Articles::Id))
                    .col(string_null(Articles::Title))
                    .col(text(Articles::Content))
                    .to_owned(),
            )
            .await
    }

    async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
        manager
            .drop_table(Table::drop().table(Articles::Table).to_owned())
            .await
    }
}

You can recreate a complete database by applying migrations in-series onto a fresh database -- this is done automatically by Loco's migrator (which is derived from SeaORM).

When generating a new model, Loco will:

  • Generate a new "up" database migration
  • Apply the migration
  • Reflect the entities from database structure and generate back your _entities code

You will find your new model as an entity, synchronized from your database structure in models/_entities/:

src/models/
├── _entities
│   ├── articles.rs  <-- sync'd from db schema, do not edit
│   ├── mod.rs
│   ├── prelude.rs
│   └── users.rs
├── articles.rs   <-- generated for you, your logic goes here.
├── mod.rs
└── users.rs

Using playground to interact with the database

Your examples/ folder contains:

  • playground.rs - a place to try out and experiment with your models and app logic.

Let's fetch data using your models, using playground.rs:

// located in examples/playground.rs
// use this file to experiment with stuff
use loco_rs::{cli::playground, prelude::*};
// to refer to articles::ActiveModel, your imports should look like this:
use myapp::{app::App, models::_entities::articles};

#[tokio::main]
async fn main() -> loco_rs::Result<()> {
    let ctx = playground::<App>().await?;

    // add this:
    let res = articles::Entity::find().all(&ctx.db).await.unwrap();
    println!("{:?}", res);

    Ok(())
}

Return a list of posts

In the example, we use the following to return a list:

let res = articles::Entity::find().all(&ctx.db).await.unwrap();

To see how to run more queries, go to the SeaORM docs.

To execute your playground, run:

$ cargo playground
[]

Now, let's insert one item:

async fn main() -> loco_rs::Result<()> {
    let ctx = playground::<App>().await?;

    // add this:
    let active_model: articles::ActiveModel = articles::ActiveModel {
        title: Set(Some("how to build apps in 3 steps".to_string())),
        content: Set(Some("use Loco: https://loco.rs".to_string())),
        ..Default::default()
    };
    active_model.insert(&ctx.db).await.unwrap();

    let res = articles::Entity::find().all(&ctx.db).await.unwrap();
    println!("{:?}", res);

    Ok(())
}

And run the playground again:

$ cargo playground
[Model { created_at: ..., updated_at: ..., id: 1, title: Some("how to build apps in 3 steps"), content: Some("use Loco: https://loco.rs") }]

We're now ready to plug this into an articles controller. First, generate a new controller:

$ cargo loco generate controller articles --api
added: "src/controllers/articles.rs"
injected: "src/controllers/mod.rs"
injected: "src/app.rs"
added: "tests/requests/articles.rs"
injected: "tests/requests/mod.rs"

Edit src/controllers/articles.rs:

#![allow(clippy::unused_async)]
use loco_rs::prelude::*;

use crate::models::_entities::articles;

pub async fn list(State(ctx): State<AppContext>) -> Result<Response> {
    let res = articles::Entity::find().all(&ctx.db).await?;
    format::json(res)
}

pub fn routes() -> Routes {
    Routes::new().prefix("api/articles").add("/", get(list))
}

Now, start the app:

cargo loco start

And make a request:

$ curl localhost:5150/api/articles
[{"created_at":"...","updated_at":"...","id":1,"title":"how to build apps in 3 steps","content":"use Loco: https://loco.rs"}]

Building a CRUD API

Next we'll see how to get a single article, delete, and edit a single article. Getting an article by ID is done using the Path extractor from axum.

Replace the contents of articles.rs with this:

// this is src/controllers/articles.rs

#![allow(clippy::unused_async)]
use loco_rs::prelude::*;
use serde::{Deserialize, Serialize};

use crate::models::_entities::articles::{ActiveModel, Entity, Model};

#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Params {
    pub title: Option<String>,
    pub content: Option<String>,
}

impl Params {
    fn update(&self, item: &mut ActiveModel) {
        item.title = Set(self.title.clone());
        item.content = Set(self.content.clone());
    }
}

async fn load_item(ctx: &AppContext, id: i32) -> Result<Model> {
    let item = Entity::find_by_id(id).one(&ctx.db).await?;
    item.ok_or_else(|| Error::NotFound)
}

pub async fn list(State(ctx): State<AppContext>) -> Result<Response> {
    format::json(Entity::find().all(&ctx.db).await?)
}

pub async fn add(State(ctx): State<AppContext>, Json(params): Json<Params>) -> Result<Response> {
    let mut item: ActiveModel = Default::default();
    params.update(&mut item);
    let item = item.insert(&ctx.db).await?;
    format::json(item)
}

pub async fn update(
    Path(id): Path<i32>,
    State(ctx): State<AppContext>,
    Json(params): Json<Params>,
) -> Result<Response> {
    let item = load_item(&ctx, id).await?;
    let mut item = item.into_active_model();
    params.update(&mut item);
    let item = item.update(&ctx.db).await?;
    format::json(item)
}

pub async fn remove(Path(id): Path<i32>, State(ctx): State<AppContext>) -> Result<Response> {
    load_item(&ctx, id).await?.delete(&ctx.db).await?;
    format::empty()
}

pub async fn get_one(Path(id): Path<i32>, State(ctx): State<AppContext>) -> Result<Response> {
    format::json(load_item(&ctx, id).await?)
}

pub fn routes() -> Routes {
    Routes::new()
        .prefix("api/articles")
        .add("/", get(list))
        .add("/", post(add))
        .add("/:id", get(get_one))
        .add("/:id", delete(remove))
        .add("/:id", patch(update))
}

A few items to note:

  • Params is a strongly typed required params data holder, and is similar in concept to Rails' strongparams, just safer.
  • Path(id): Path<i32> extracts the :id component from a URL.
  • Order of extractors is important and follows axum's documentation (parameters, state, body).
  • It's always better to create a load_item helper function and use it in all singular-item routes.
  • While use loco_rs::prelude::* brings in anything you need to build a controller, you should note to import crate::models::_entities::articles::{ActiveModel, Entity, Model} as well as Serialize, Deserialize for params.
The order of the extractors is important, as changing the order of them can lead to compilation errors. Adding the #[debug_handler] macro to handlers can help by printing out better error messages. More information about extractors can be found in the axum documentation.

You can now test that it works, start the app:

cargo loco start

Add a new article:

$ curl -X POST -H "Content-Type: application/json" -d '{
  "title": "Your Title",
  "content": "Your Content xxx"
}' localhost:5150/api/articles
{"created_at":"...","updated_at":"...","id":2,"title":"Your Title","content":"Your Content xxx"}

Get a list:

$ curl localhost:5150/api/articles
[{"created_at":"...","updated_at":"...","id":1,"title":"how to build apps in 3 steps","content":"use Loco: https://loco.rs"},{"created_at":"...","updated_at":"...","id":2,"title":"Your Title","content":"Your Content xxx"}

Adding a second model

Let's add another model, this time: Comment. We want to create a relation - a comment belongs to a post, and each post can have multiple comments.

Instead of coding the model and controller by hand, we're going to create a comment scaffold which will generate a fully working CRUD API comments. We're also going to use the special references type:

$ cargo loco generate scaffold comment content:text article:references --api
The special references:<table> is also available. For when you want to have a different name for your column.

If you peek into the new migration, you'll discover a new database relation in the articles table:

      ..
      ..
  .col(integer(Comments::ArticleId))
  .foreign_key(
      ForeignKey::create()
          .name("fk-comments-articles")
          .from(Comments::Table, Comments::ArticleId)
          .to(Articles::Table, Articles::Id)
          .on_delete(ForeignKeyAction::Cascade)
          .on_update(ForeignKeyAction::Cascade),
  )
      ..
      ..

Now, lets modify our API in the following way:

  1. Comments can be added through a shallow route: POST comments/
  2. Comments can only be fetched in a nested route (forces a Post to exist): GET posts/1/comments
  3. Comments cannot be updated, fetched singular, or deleted

In src/controllers/comments.rs, remove unneeded routes and functions:

pub fn routes() -> Routes {
    Routes::new()
        .prefix("api/comments")
        .add("/", post(add))
        // .add("/", get(list))
        // .add("/:id", get(get_one))
        // .add("/:id", delete(remove))
        // .add("/:id", patch(update))
}

Also adjust the Params & update functions in src/controllers/comments.rs, by updating the scaffolded code marked with <- add this

pub struct Params {
    pub content: Option<String>,
    pub article_id: i32, // <- add this
}

impl Params {
    fn update(&self, item: &mut ActiveModel) {
        item.content = Set(self.content.clone());
        item.article_id = Set(self.article_id.clone()); // <- add this
    }
}

Now we need to fetch a relation in src/controllers/articles.rs. Add the following route:

pub fn routes() -> Routes {
  // ..
  // ..
  .add("/:id/comments", get(comments))
}

And implement the relation fetching:

// to refer to comments::Entity, your imports should look like this:
use crate::models::_entities::{
    articles::{ActiveModel, Entity, Model},
    comments,
};

pub async fn comments(
    Path(id): Path<i32>,
    State(ctx): State<AppContext>,
) -> Result<Response> {
    let item = load_item(&ctx, id).await?;
    let comments = item.find_related(comments::Entity).all(&ctx.db).await?;
    format::json(comments)
}
This is called "lazy loading", where we fetch the item first and later its associated relation. Don't worry - there is also a way to eagerly load comments along with an article.

Now start the app again:

cargo loco start

Add a comment to Article 1:

$ curl -X POST -H "Content-Type: application/json" -d '{
  "content": "this rocks",
  "article_id": 1
}' localhost:5150/api/comments
{"created_at":"...","updated_at":"...","id":4,"content":"this rocks","article_id":1}

And, fetch the relation:

$ curl localhost:5150/api/articles/1/comments
[{"created_at":"...","updated_at":"...","id":4,"content":"this rocks","article_id":1}]

This ends our comprehensive Guide to Loco. If you made it this far, hurray!.

Tasks: export data report

Real world apps require handling real world situations. Say some of your users or customers require some kind of a report.

You can:

  • Connect to your production database, issue ad-hoc SQL queries. Or use some kind of DB tool. This is unsafe, insecure, prone to errors, and cannot be automated.
  • Export your data to something like Redshift, or Google, and issue a query there. This is a waste of resource, insecure, cannot be tested properly, and slow.
  • Build an admin. This is time-consuming, and waste.
  • Or build an adhoc task in Rust, which is quick to write, type safe, guarded by the compiler, fast, environment-aware, testable, and secure.

This is where cargo loco task comes in.

First, run cargo loco task to see current tasks:

$ cargo loco task
seed_data		[Task for seeding data]

Generate a new task user_report

$ cargo loco generate task user_report

added: "src/tasks/user_report.rs"
injected: "src/tasks/mod.rs"
injected: "src/app.rs"
added: "tests/tasks/user_report.rs"
injected: "tests/tasks/mod.rs"

In src/tasks/user_report.rs you'll see the task that was generated for you. Replace it with following:

// find it in `src/tasks/user_report.rs`

use loco_rs::prelude::*;
use loco_rs::task::Vars;

use crate::models::users;

#[async_trait]
impl Task for UserReport {
    fn task(&self) -> TaskInfo {
      // description that appears on the CLI
        TaskInfo {
            name: "user_report".to_string(),
            detail: "output a user report".to_string(),
        }
    }

    // variables through the CLI:
    // `$ cargo loco task name:foobar count:2`
    // will appear as {"name":"foobar", "count":2} in `vars`
    async fn run(&self, app_context: &AppContext, vars: &Vars) -> Result<()> {
        let users = users::Entity::find().all(&app_context.db).await?;
        println!("args: {vars:?}");
        println!("!!! user_report: listing users !!!");
        println!("------------------------");
        for user in &users {
            println!("user: {}", user.email);
        }
        println!("done: {} users", users.len());
        Ok(())
    }
}

You can modify this task as you see fit. Access the models with app_context, or any other environmental resources, and fetch variables that were given through the CLI with vars.

Running this task is done with:

$ cargo loco task user_report var1:val1 var2:val2 ...

args: Vars { cli: {"var1": "val1", "var2": "val2"} }
!!! user_report: listing users !!!
------------------------
done: 0 users

If you have not added an user before, the report will be empty.

To add an user check out chapter Registering a New User of A Quick Tour with Loco.

Remember: this is environmental, so you write the task once, and then execute in development or production as you wish. Tasks are compiled into the main app binary.

Authentication: authenticating your requests

If you chose the SaaS App starter, you should have a fully configured authentication module baked into the app. Let's see how to require authentication when adding comments.

Go back to src/controllers/comments.rs and take a look at the add function:

pub async fn add(State(ctx): State<AppContext>, Json(params): Json<Params>) -> Result<Response> {
    let mut item: ActiveModel = Default::default();
    params.update(&mut item);
    let item = item.insert(&ctx.db).await?;
    format::json(item)
}

To require authentication, we need to modify the function signature in this way:

async fn add(
    auth: auth::JWT,
    State(ctx): State<AppContext>,
    Json(params): Json<Params>,
) -> Result<Response> {
    // we only want to make sure it exists
    let _current_user = crate::models::users::Model::find_by_pid(&ctx.db, &auth.claims.pid).await?;

    // next, update
    // homework/bonus: make a comment _actually_ belong to user (user_id)
    let mut item: ActiveModel = Default::default();
    params.update(&mut item);
    let item = item.insert(&ctx.db).await?;
    format::json(item)
}