DEV Community

Alex Spinov
Alex Spinov

Posted on

Loco.rs Has a Free API: Ruby on Rails for Rust — Full-Stack Web Framework

Loco is a Rust web framework inspired by Ruby on Rails. It provides generators, ORM, background jobs, mailers, and deployment — all the batteries Rails includes, but with Rust's performance and safety.

Why Loco Matters

Rust web development usually means assembling 10+ crates: Axum for routing, SeaORM for database, Tokio for async, custom solutions for jobs, auth, and mailers. Loco bundles everything into one opinionated framework.

What you get for free:

  • Rails-like generators (scaffold, model, controller, migration)
  • SeaORM integration with automatic migrations
  • Background jobs with SidekiqWorker-like API
  • Built-in authentication (JWT + session)
  • Mailer with templates
  • Testing framework
  • One-command deployment
  • CLI similar to rails

Quick Start

# Install
cargo install loco-cli

# Create project
loco new my-app
cd my-app

# Generate scaffold (model + controller + migration)
cargo loco generate scaffold post title:string body:text published:bool

# Run migrations
cargo loco db migrate

# Start server
cargo loco start
Enter fullscreen mode Exit fullscreen mode

Controllers (Routes)

use axum::extract::{Path, State};
use loco_rs::prelude::*;
use crate::models::_entities::posts;

async fn list(
    State(ctx): State<AppContext>,
) -> Result<Response> {
    let posts = posts::Entity::find()
        .order_by_desc(posts::Column::CreatedAt)
        .all(&ctx.db)
        .await?;
    format::json(posts)
}

async fn get_one(
    Path(id): Path<i32>,
    State(ctx): State<AppContext>,
) -> Result<Response> {
    let post = posts::Entity::find_by_id(id)
        .one(&ctx.db)
        .await?
        .ok_or_else(|| Error::NotFound)?;
    format::json(post)
}

async fn create(
    State(ctx): State<AppContext>,
    Json(params): Json<CreatePostParams>,
) -> Result<Response> {
    let post = posts::ActiveModel {
        title: Set(params.title),
        body: Set(params.body),
        published: Set(params.published),
        ..Default::default()
    }
    .insert(&ctx.db)
    .await?;
    format::json(post)
}

pub fn routes() -> Routes {
    Routes::new()
        .prefix("posts")
        .add("/", get(list))
        .add("/:id", get(get_one))
        .add("/", post(create))
}
Enter fullscreen mode Exit fullscreen mode

Models (SeaORM)

use sea_orm::entity::prelude::*;

#[derive(Clone, Debug, DeriveEntityModel)]
#[sea_orm(table_name = "posts")]
pub struct Model {
    #[sea_orm(primary_key)]
    pub id: i32,
    pub title: String,
    pub body: String,
    pub published: bool,
    pub created_at: DateTimeWithTimeZone,
    pub updated_at: DateTimeWithTimeZone,
}

// Migrations are auto-generated
Enter fullscreen mode Exit fullscreen mode

Background Jobs

use loco_rs::prelude::*;

pub struct SendWelcomeEmail;

#[async_trait]
impl Worker<SendWelcomeEmailArgs> for SendWelcomeEmail {
    async fn perform(&self, args: SendWelcomeEmailArgs) -> Result<()> {
        let user = users::Entity::find_by_id(args.user_id)
            .one(&self.ctx.db)
            .await?;

        mailers::auth::welcome(&user).deliver(&self.ctx).await?;
        Ok(())
    }
}

// Enqueue a job
SendWelcomeEmail::perform_later(&ctx, SendWelcomeEmailArgs {
    user_id: user.id,
}).await?;
Enter fullscreen mode Exit fullscreen mode

Authentication (Built-in)

use loco_rs::prelude::*;

// Protect routes with JWT
async fn protected_route(
    auth: auth::JWT,
    State(ctx): State<AppContext>,
) -> Result<Response> {
    let user = users::Model::find_by_pid(&ctx.db, &auth.claims.pid).await?;
    format::json(user)
}

// Login endpoint (auto-generated)
async fn login(
    State(ctx): State<AppContext>,
    Json(params): Json<LoginParams>,
) -> Result<Response> {
    let user = users::Model::find_by_email(&ctx.db, &params.email).await?;
    let valid = user.verify_password(&params.password);
    if !valid {
        return unauthorized("Invalid credentials");
    }
    let token = user.generate_jwt(&ctx.config.auth.jwt.secret, 24)?;
    format::json(LoginResponse { token })
}
Enter fullscreen mode Exit fullscreen mode

Testing

#[tokio::test]
async fn test_create_post() {
    testing::request::<App, _, _>(|request, ctx| async move {
        let response = request
            .post("/api/posts")
            .json(&serde_json::json!({
                "title": "Test Post",
                "body": "This is a test",
                "published": true
            }))
            .await;

        assert_eq!(response.status_code(), 200);
        let body: serde_json::Value = response.json();
        assert_eq!(body["title"], "Test Post");
    })
    .await;
}
Enter fullscreen mode Exit fullscreen mode

Useful Links


Building Rust-powered APIs? Check out my developer tools on Apify for ready-made web scrapers, or email spinov001@gmail.com for custom solutions.

Top comments (0)