Back to Loco

The Tour

docs-site/content/docs/tutorials/the-tour.md

1.1.010.0 KB
Original Source

+++ title = "The Tour" description = "A faster, end-to-end walkthrough: a model with a relation, a hand-wired controller route, a background worker, and a task — in one sitting." date = 2021-05-01T18:10:00+00:00 updated = 2021-05-01T18:10:00+00:00 draft = false weight = 2 sort_by = "weight" template = "docs/page.html" aliases = ["/docs/getting-started/tour/"]

[extra] lead = "" toc = true top = false +++

This tour moves faster than Your First App and assumes you've already installed loco and sea-orm-cli and generated at least one app. In one pass, you'll touch the four pieces that make up almost every Loco feature: models, controllers, workers, and tasks. Each section links to the reference page that documents its full surface — this page only shows you the working path.

Set up

sh
loco new --name tour_app --db sqlite --bg async --assets none
cd tour_app
<div class="infobox"> Picking a database (<code>--db sqlite</code>) also gives this app a ready-made authentication suite at <code>/api/auth/*</code> — that's covered separately in <a href="@/docs/tutorials/saas-with-auth.md">Build a small authenticated app</a>. This tour ignores it and builds its own resources alongside it. </div>

Models and controllers: a scaffold, and a plain model with a relation

Generate a posts scaffold — model, migration, entity, CRUD controller, and tests in one step:

sh
$ cargo loco generate scaffold posts title:string! content:text

Now generate a comments model only (no controller yet — you'll hand-write that one) that belongs to a post, using the references field type:

sh
$ cargo loco generate model comments content:text post:references

Both commands write a migration, apply it immediately, and regenerate Sea-ORM entities — there's no separate "now run the migration" step. Open the comments migration and you'll see the relation as a plain data description, not hand-rolled SQL builder calls:

rust
// migration/src/mYYYYMMDD_HHMMSS_comments.rs
use loco_rs::schema::*;
use sea_orm_migration::prelude::*;

#[derive(DeriveMigrationName)]
pub struct Migration;

#[async_trait::async_trait]
impl MigrationTrait for Migration {
    async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> {
        create_table(m, "comments",
            &[
            ("id", ColType::PkAuto),
            ("content", ColType::TextNull),
            ],
            &[
            ("post", ""),
            ]
        ).await
    }

    async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
        drop_table(m, "comments").await
    }
}

("post", "") means "add a foreign key to posts, and figure out the column name for me" — it becomes a required post_id column. That column is a 64-bit integer (BigInteger), matching the 64-bit auto-increment primary keys Loco 1.0 uses everywhere; see Schema & ColType DSL for the full column-type story.

Controllers: a generated one, and one you wire by hand

posts already has a full CRUD controller from its scaffold. comments only has a model so far — scaffold generates model and controller together, while model only builds the data layer. Reach for controller when you already have a model and just need a thin API on top:

sh
$ cargo loco generate controller comments

With no action names given, this stubs a single index action returning an empty body — it has no idea about your model's columns. Replace src/controllers/comments.rs entirely with a small, purpose-built API: comments can only be added through a shallow route, and listed through a nested route under their post — never fetched singly, updated, or deleted:

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

use crate::models::_entities::comments::{ActiveModel, Entity};

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

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

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 fn routes() -> Routes {
    Routes::new()
        .prefix("api/comments/")
        .add("/", post(add))
}

Now add the nested read on the posts side. In src/controllers/posts.rs (generated by the scaffold), add a route and a handler that loads a post's comments through the relation Sea-ORM generated for you:

rust
// add to the existing imports
use crate::models::_entities::comments;

pub async fn comments_for_post(
    Path(id): Path<i64>,
    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)
}

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

Try it:

sh
cargo loco start
sh
$ curl -X POST -H "Content-Type: application/json" -d '{"title":"Tour post","content":"hi"}' localhost:5150/api/posts
{"id":1,...}

$ curl -X POST -H "Content-Type: application/json" -d '{"content":"nice post","post_id":1}' localhost:5150/api/comments
{"id":1,...}

$ curl localhost:5150/api/posts/1/comments
[{"id":1,"content":"nice post","post_id":1,...}]

Workers: do something in the background

Generate a worker and see it register itself in src/app.rs:

sh
$ cargo loco generate worker notifier
added: "src/workers/notifier.rs"
injected: "src/workers/mod.rs"
injected: "src/app.rs"

The generated struct is always plainly named Worker, namespaced by its own module (workers::notifier::Worker) — that's how you'll refer to it. Edit src/workers/notifier.rs's WorkerArgs and perform:

rust
#[derive(Deserialize, Debug, Serialize)]
pub struct WorkerArgs {
    pub post_id: i64,
}

#[async_trait]
impl BackgroundWorker<WorkerArgs> for Worker {
    fn build(ctx: &AppContext) -> Self {
        Self { ctx: ctx.clone() }
    }
    async fn perform(&self, args: WorkerArgs) -> Result<()> {
        println!("new comment/notification for post {}", args.post_id);
        Ok(())
    }
}

Now enqueue it from posts::add in src/controllers/posts.rs, right after the post is inserted:

rust
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?;

    crate::workers::notifier::Worker::perform_later(
        &ctx,
        crate::workers::notifier::WorkerArgs { post_id: item.id },
    )
    .await?;

    format::json(item)
}

Because you generated this app with --bg async, workers.mode in config/development.yaml is BackgroundAsync — the job runs in-process, no extra --worker process needed. Restart with cargo loco start, POST a new post, and watch the worker's println! appear in the same terminal.

Async-in-process is a development convenience. For a real deployment you'd typically move to a Redis-, Postgres-, or SQLite-backed queue and run workers as their own process(es) with cargo loco start --worker; see Add a background worker for the async-vs-queue tradeoff.

Tasks: a one-off, run from the CLI

Generate a task:

sh
$ cargo loco generate task posts_report

Edit src/tasks/posts_report.rs:

rust
use loco_rs::prelude::*;

use crate::models::_entities::posts;

pub struct PostsReport;
#[async_trait]
impl Task for PostsReport {
    fn task(&self) -> TaskInfo {
        TaskInfo {
            name: "posts_report".to_string(),
            detail: "Print every post's title".to_string(),
        }
    }
    async fn run(&self, app_context: &AppContext, _vars: &task::Vars) -> Result<()> {
        let posts = posts::Entity::find().all(&app_context.db).await?;
        for post in &posts {
            println!("- {}", post.title);
        }
        println!("done: {} posts", posts.len());
        Ok(())
    }
}

List and run it:

sh
$ cargo loco task
posts_report        [Print every post's title]

$ cargo loco task posts_report
- Tour post
done: 1 posts

Tasks are compiled into your app binary and are environment-aware (cargo loco task posts_report -e production runs against production config) — a safer alternative to ad-hoc SQL against a live database. Tasks can also be triggered on a schedule; see Write a one-off task and Schedule recurring jobs.

See everything you just wired

sh
$ cargo loco routes

lists every route across every controller — the scaffolded posts CRUD, the hand-written comments routes, the nested posts/{id}/comments route you added by hand, and the /api/auth/* suite that came bundled with the database.

Next