Getting Started with Loco in Rust: Part 1

Cover image

In this article we're going to talk about how you can get started with Loco - a new Rust web framework that builds on Axum and takes inspiration from Ruby on Rails. We will cover getting started using controllers, migrations, middleware and static files. Following on from our previous article, we're going to get more indepth and experiment with creating a CRUD controller as well as middleware.

Getting Started

To get started, you will need to make sure to have Loco's CLI installed by using the following:

cargo install loco-cli

Loco also uses sea-orm-cli to carry out database migrations. You can install it using the following:

cargo install sea-orm-cli

Now we've installed all of the required packages, we can get initialise our project. We can get started by using loco new and then selecting the SaaS application which will give us an app with full functionality. The given name we will be using for the app will be example_app. Don't forget to cd into the project folder!

When you're writing your API, you will probably want to spin up a local Docker database for database testing. In that case, you will wnat to use this docker command:

$ docker run -d -p 5432:5432 -e POSTGRES_USER=loco -e POSTGRES_DB=example_app_development -e POSTGRES_PASSWORD="loco" postgres:15.3-alpine

Routing in Loco

The first step that we need to take will be generating a "scaffold". This generates a controller, model and migration all at the same time. We can also add pre-generated field names and types beforehand, which you can find more about here. We can create our own scaffold below:

cargo loco generate scaffold item name:string! description:string quantity:int!

This will generate a controller, model and migration for a table named items with:

  • A non-nullable name field
  • A nullable description field
  • A non-nullable quantity field

The entities will also be generated so that you should not need to generate them yourself.

Once the scaffold is done, you may notice that your Loco controller and other relevant parts have been added in app.rs, so there is no need to add it manually.

Now we can go to our new controller file which should be located under src/controllers/item.rs. When opened, we should be greeted with something that looks like this:

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

pub async fn echo(req_body: String) -> String {
    req_body
}

pub async fn hello(State(_ctx): State<AppContext>) -> Result<String> {
    format::string("Hello world!")
}

pub fn routes() -> Routes {
    Routes::new()
        .prefix("item")
        .add("/", get(hello))
        .add("/echo", post(echo))

Now we can get to work on the routes for this!

If you check the source code for what AppContext contains here, you should get this:

#[derive(Clone)]
#[allow(clippy::module_name_repetitions)]
pub struct AppContext {
    /// The environment in which the application is running.
    pub environment: Environment,
    #[cfg(feature = "with-db")]
    /// A database connection used by the application.    
    pub db: DatabaseConnection,
    /// An optional connection pool for Redis, for worker tasks
    pub redis: Option<Pool<RedisConnectionManager>>,
    /// Configuration settings for the application
    pub config: Config,
    /// An optional email sender component that can be used to send email.
    pub mailer: Option<EmailSender>,
}

This means we only need to use ctx.db to access the database connection. Let's have a look at what a simple request for getting all of the item records from the database would look like:

#![allow(clippy::unused_async)]
use loco_rs::prelude::*;
use crate::models::_entities::items::Entity as Item;
use crate::models::_entities::items::Model as ItemModel;

pub async fn hello(State(ctx): State<AppContext>) -> Result<Json<Vec<ItemModel>>> {
    let items = Item::find().all(&ctx.db).await?;
    format::json(items)
}

Note that we need to import our models and entities from the entities folder.

We can extend this to create a full CRUD controller:

use crate::models::_entities::items::ActiveModel;

pub async fn view_item_by_id(Path<id>: Path<i32>, State(ctx): State<AppContext>) -> Result<Json<ItemModel>> {
    let item: Option<item::Model> = Item::find_by_id(id).one(db).await?;
    let item: item::Model = item.unwrap();

    format::json(item)
}

pub async fn create_item(
    State(ctx): State<AppContext>,
    Json(item): Json<ItemModel>
) -> Result<String> {

    let item: ActiveModel = item.into();
    item.insert(&ctx.db).await?;

    format::text("Created")

}

#[derive(Deserialize)]
struct ItemQty { qty: i32 };

pub async fn update_item_quantity(
    State(ctx): State<AppContext>,
    Path(id): Path<i32>,
    Json(json): Json<ItemQty>
) -> Result<String> {

    let item: Option<item::Model> = Item::find_by_id(id).one(&ctx.db).await?;
    let mut item: item::ActiveModel = item.unwrap().into();

    item.quantity = Set(json.qty);
    let updateditem = item.update(db).await?;

    format::text("Updated")

}

pub async fn delete_item(
	Path<id>: Path<i32>, 
	State(ctx): State<AppContext>
) -> Result<String> {
    let item: Option<item::Model> = Item::find_by_id(id).one(db).await?;
    let item: item::Model = item.unwrap();

    let res: DeleteResult = item.delete(db).await?;

    format::text("Deleted")
}

Once you're done writing all of your routes, you need to make sure you attach them to your router in the routes() function for the controller file:


pub fn routes() -> Routes {
    Routes::new()
        .prefix("items")
        .add("/", get(get_all_items).post(create_item))
        .add("/:id", get(get_item_by_id).put(update_item_qty).delete(delete_item))
}

Congrats! You just created your first full CRUD router.

To build onto this, let's add some validation for when you need to save an item. Loco itself re-exports the validator crate which allows you to validate that a struct meets certain requirements. Loco ties this in with sea_orm to be able to validate a struct before saving it to the database. A validator struct might look like this:

#[derive(Debug, Validate, Deserialize)]
pub struct ModelValidator {
    #[validate(range(min = 0, message = "Item must have at least a quantity of 0."))]
    pub quantity: i32,
}

Now that this is done, we just need to implement From<Model> for the validator struct, and implement the ActiveModelBehavior trait for the ActiveModel. Note that because we have to always convert a Model to an ActiveModel before saving it, the before_save function will always kick in.

impl From<&ActiveModel> for ModelValidator {
    fn from(value: &ActiveModel) -> Self {
        Self {
            quantity: *value.quantity.as_ref(),
        }
    }
}

#[async_trait::async_trait]
impl ActiveModelBehavior for super::_entities::items::ActiveModel {
    async fn before_save<C>(self, db: &C, insert: bool) -> Result<Self, DbErr>
    where
        C: ConnectionTrait,
    {
        {
            self.validate()?;
            Ok(self)
        }
    }
}

You can also write an impl for the Model itself to extend its behavior! Note that you will need to pass in the database connection as a function parameter. Check out this function for finding users by emails (can be found in the pre-generated users::Model model):

// src/models/users.rs
pub async fn find_by_email(db: &DatabaseConnection, email: &str) -> ModelResult<Self> 
    let user = users::Entity::find()
        .filter(users::Column::Email.eq(email))
        .one(db)
        .await?;

    user.ok_or_else(|| ModelError::EntityNotFound)
}

Middleware in Loco

Middleware in Loco can be implemented in a few ways:

  1. Implementing axum::FromRequestParts (or FromRequest) for a given struct or enum

  2. Implmenting the optional after_routes() method in the Hooks trait (in app.rs)

Implementing FromRequest is probably the easiest way to go about being able to implement middleware for select routes, while after_routes() is likely much better for globally implementing a middleware (for example, a timeout).

Using FromRequestParts

Although FromRequestParts (and FromRequest respectively) look tricky to implement, you can make it substantially easier on yourself by remembering that the state itself just needs to implement Send + Sync - which means you can use AppContext with it! Check out the code snippet below for an overall implementation of how you would write something that implements FromRequestParts.

#[derive(Deserialize)]
pub struct MyMiddlewareState(String);

#[async_trait::async_trait]
impl<AppContext> FromRequestParts<AppContext> for MyMiddlewareState
{
    type Rejection = ApiError;

    async fn from_request_parts(parts: &mut Parts, _state: &AppContext) -> Result<Self, Self::Rejection> {
        let string = "Hello world!".to_string();

        if string != *"Hello world!" {
            return Err(ApiError::Unknown);
        }

        Ok(MyMiddlewareState(string))
    }
}

enum ApiError { Unknown }

impl IntoResponse for ApiError {
    // ... implement IntoResponse for ApiError for it to return a HTTP response
}

Of course, in a real-world application this will be much more extensive than assigning "Hello world!" to a variable and then returning the struct.

Using Global Middleware

As mentioned before, you can also use the after_routes() function in the Hooks trait. The function itself looks like this:

async fn after_routes(router: AxumRouter, _ctx: &AppContext) -> Result<AxumRouter> {
    Ok(router)
}

Because the Axum router gets passed in as a parameter, you can attach any kind of Axum middleware or layer you want. This also means you can add things like a tower-http service layer if you'd like! Let's have a look at adding a timeout layer, which will stop any slow loris attacks. To do this, we'll need to add tower-http with the timeout feature:

cargo add tower-http -F timeout

Then we just need to add the layer:

async fn after_routes(router: AxumRouter, _ctx: &AppContext) -> Result<AxumRouter> {
    let router = router.layer(TimeoutLayer::new(Duration::from_secs(10)));

    Ok(router)
}

Now any requests that last longer than 10 seconds will automatically be aborted with a 408 Request Timeout response. Pretty nifty!

We can also implement our own Tower service, which you can find more about here.

Serving a Frontend in Loco

Serving a frontend with Loco is as easy as going into the frontend folder, then running npm i to install the dependencies. Then you run npm run build to build your application. When you use cargo loco start and go to localhost:8000 you should see the main screen for the Loco.rs homepage but blank.

Note that the main frontend approach uses React. You can switch this out for any other framework you like. The only thing you need to do is to make sure the frontend you are serving matches the config file under the static section (the folder key, with the default being /frontend/dist). If you're a Svelte or Vue user, or want to use Leptos or Dioxus you can freely switch around! If you are not experienced in any of the aforementioned frameworks, you can also use raw HTML/CSS/JS. This may be particularly more favourable if you either don't have a lot of HTML/CSS you need to serve.

Deploying Loco

Loco have provided their own commands for generating a deployment. You can run it by using cargo loco generate deployment. It will then generate a Dockerfile or Shuttle deployment depending on what you select. If you're deploying with Shuttle, don't forget you can add your frontend assets by going to your Shuttle.toml file and then adding frontend/dist to your assets key:

name = "<name-of-your-project>"
assets = ["frontend/dist/*"]

Then you can run the following to deploy your application (don't forget to install cargo-shuttle):

cargo shuttle project start
cargo shuttle deploy 

We are planning to release a native database integration with Loco! Stay tuned for part 2 where we will go into further detail about how you can build out your dream web application.

Finishing Up

Thanks for reading! We hope this Rust Loco guide has helped you. If you're looking to get started with Rust web development, now is a better time than ever to do so.

Interested in more?

  • Read about our getting started with Axum article here
  • Learn about how you can implement OAuth for your application here
This blog post is powered by shuttle - The Rust-native, open source, cloud development platform. If you have any questions, or want to provide feedback, join our Discord server!
Share article
rocket

Build the Future of Backend Development with us

Join the movement and help revolutionize the world of backend development. Together, we can create the future!