Introducing Loco: the "Rust on Rails"

Posted on 2024-02-24

Introduction to Loco

Loco is a Web or API framework for Rust: a "Rust on Rails". Strongly inspired by Rails, it contains everything you need to go from side project to a startup in Rust.

Loco is built by @jondot and @kaplanelad together with the Loco contributors.

The Genesis of Loco: Bridging the Gap in Web Development

In order to build Loco, we had to develop our set of: principles, guides, philosophies and opinions to answer:

  • Where should we make a difference?
  • How can we fully take advantage of Rust's abilities?
  • How can we make hard things simple, and impossible things possible?

Believe it or not, Loco is a 3rd rewrite of building a Rails alternative which is not based on Ruby.

The first iteration was originally built on Node.js, a stack of high-productivity libraries glued together with some special sauce, made building a SaaS for a startup a breeze. It became: hyperstackjs, a "Rails on Typescript".

But Node.js and the Javascript/Typescript ecosystem has too much mental fatigue. I could not handle the churn, the ecosystem. Some times it took as little as 4 months to go back to a project and find that it needed so many upgrades for dependencies, and everything broke.

So how about a crazy idea: can this be built in Rust?

Crazy because Rust is rigid, static, safe. On the surface traits that are supposed to be opposite to what Rails and Ruby are: free, easy, flexible, productive.

You'd be surprised to know that Rust is perfect for this job. The first port of Hyperstack to Rust was called RustyRails and then changed into Loco.

Today, all the boxes were checked. I have zero mental overhead, zero churn of dependencies, zero fatigue working with Rust and Loco.

Oh, and I got 100,000req/s back with zero effort. This is Rust.

Overview of Loco: Core Features and Philosophies

A framework is all about balances. Loco creates balance over these principles:

  • Safe, robust software powered by Rust
  • That can be played with, experimented with, iterated on rapidly
  • Offers everything Rails has which includes: data access, controllers, views, background jobs, websockets, mailers, storage, and even things that are offered from Rails gems such as authentication, i18n, pagination -- all built in.
  • Has an easy and simple ergonomics and API surface area for developers.

Why Rust? Understanding the Language Behind Loco

First off, if you love Ruby, and happy with it -- stop here and go with Rails. Rails is fantastic, and there is nothing that can compare to it.

However, if Ruby is not something you use or can use, you most probably have 3 valid and popular options:

  • Node.js
  • Go
  • Java/JVM

None of these have a proper Rails-like framework. Node.js has some options which can function as a partially implemented Rails in terms of features (e.g. Adonis and others).

But Node.js has Javascript fatigue, which, I have to say, in 2024 is only becoming exponentially worse and not better.

Rust can be more expressive and much more safe than Go, and can be more performant, simpler, requires less tweaks and quirks and lighter on systems than the JVM.

It is the perfect language for developer happiness, zero effort performance, simple and robust software that is easy to operate in production.

Language Differences: Rust vs. Ruby

You may be coming from Ruby or Rust, but not both. If that is the case,here's a quick comparison between the two to get some initial perspective.

Purpose and Design Philosophy

  • Rust: Systems programming, emphasizing performance and safety.
  • Ruby: Emphasizes simplicity and productivity, often used for web development.

Memory Management

  • Rust: Ownership model, no garbage collector.
  • Ruby: Garbage-collected, simpler but with performance overhead.

Type System

  • Rust: Strong, static type system with type inference.
  • Ruby: Dynamic typing, flexible but prone to runtime errors.

Performance

  • Rust: High performance, comparable to C/C++.
  • Ruby: Generally slower due to being interpreted.

Concurrency

  • Rust: Advanced concurrency support, avoids data races.
  • Ruby: Supports concurrency, but hindered by Global Interpreter Lock (GIL).

Community and Ecosystem

  • Rust: Growing, with a focus on safe concurrency. Expanding Cargo and Crates.io ecosystem.
  • Ruby: Large, established community. Rich library of gems, especially for web development.

The Modern Web Developer’s Toolkit: What Loco Brings to the Table

Simplicity

Rust dependencies are stable, simple, usually non-breaking because of the awesome Rust community. And then, there is just one tool for packages, linting, building and so on, and all tools are powered by Cargo.

With Loco you build just a single binary - that's your app. Deploy is just as easy as copying your binary to your server.

Safety & Performance

Loco is not a small framework but also not a big framework. We managed to do as little work as possible and then hand off to a time tested, stable and mature libraries to do the grunt work.

This recipe makes Loco super fast. On a typical M1 macbook a benchmark which includes database access is in the area of 30,000req/s.

Concurrency Made Simple

We run on Tokio and Axum on the server, and on Sidekiq-rs for background workers. We support a transparent and powerful concurrency model which can be switched:

  1. Async in-process, evented: powered by Tokio. Just run tokio tasks as you wish.
  2. Async background process: enqueue a task and get it performed on the background on the same machine or different machine. Uses a Redis queue under the hood.

You can switch the model from configuration.

What can $5 buy me?

Almost all architectural decision were made by building a "one person framework". That is a framework which lets you be hyper-productive, easy on your brain, but also easy on your budget.

For example, you can get a free hosting provider with very low resource numbers, and deploy your Loco app because a Loco app is just a 20mb binary, and using Rust means it is very light on memory and CPU.

You can have background jobs for free if you run async in tokio. Or you can spend $5 for a small Redis instance and have distributed background jobs.

Everything in Loco is optimized to get you started quick and cheap, on your own. You don't need a team, an architect, a specific cloud provider a budget or anything else in order to start other than your passion for your new project!

Key Features of Loco

Batteries included or Lean & mean: choose any

One of the key mindsets of Rust is to reason about cost. This is why building a "Rails on Rust" has to have knobs for developers to turn off anything that they consider too "heavy" or too "costly".

All around Loco, you can switch off parts of the framework in compile time using Rust features, and create your own learn, lightweight apps. For example:

  • You can switch off the ORM/data stack completely, or pick specific databases to compile in
  • You can remove any layer of the "MVC" model by simply deleting files (you pay no cost for not using those)
  • You can switch off the authentication flows, websockets, CLI, storage providers and more

In fact, if you go through the starters wizard, you get something similar to "Loco editions" where each starter contains various hand-selected "batteries" to include.

Rust & Rails ergonomics

We do everything in Rust. If you imagine building Rails: a flexible, maleable, sometime DSL-looking framework, in Rust -- then Rust has a lot of missing pieces to provide that fantastic "playdough-like" DSL code.

One example can be dynamic loading of modules like Rails has. In Rails when you drop a file in a folder, it is picked up and becomes live on the next app restart. Instead of fighting and providing dynamic code loading for Rust, which will be foreign to Rust developers, we decided to avoid doing that completely, which means, if you have a new controller to add to your app, you specify it the Rust way: explicitly.

// specifying routes and controllers in src/app.rs
impl Hooks for App {
  // ..
  fn routes(_ctx: &AppContext) -> AppRoutes {
      AppRoutes::with_default_routes()
          .add_route(controllers::notes::routes())
          .add_route(controllers::user::routes())
  }
  
  //..
}

We try to balance magic and Rust-isms in Loco. We work hard on ergonomics, and when in doubt we always do what a Rust programmer would expect and not what a Ruby on Rails developer would expect.

Intuitive Design and Ease of Use

Loco sticks to the MVC (Model-View-Controller) abstraction for Web frameworks. Present in many paradigm shifts since the 90's, MVC have been able to reduce complexity for developers. This is what Rails does, too.

This means you can expect a similar intuitive folder layout like Rails has:

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
channels/Contains all channels routes.

Rapid Development and Deployment

We drive rapid development with cargo loco generate:

$ cargo loco generate scaffold movie title:string

Which is similar to Rails' generators, and has the same motivation: to get you started quickly. The code loco generate creates is not just demo code, it is safe Rust. You can keep it and build on it.

We also support cargo loco deploy which will offer multiple ways to deploy, and once you select your deployment method, will generate all the necessary files and configuration for your deploy as well as modify your app if needed. This is something Rails does not have out of the box.

SaaS authentication built in

Where usually with Rails you had to pick and specify a library like Devise, we provide the entire auth stack included with Loco. You can get both JWT or API key based authentication, which is modern, safe, and with security best-pracices baked-in.

The authentication flow is end-to-end and fully customizable, from registration to verification emails and more.

Test first design

Every component is fully testable. Rust being a static and safe programming language, achieving this key principle is much more complex than any other dynamic language, where you can monkey-patch, inject, or dynamically change code.

That said, Loco provides all needed facilities to make testing a breeze. From a convenient and ergonomic test kit for custom tailored for each app layer (models, views, workers, etc.) to snapshot testing to save on typing big robotic right-left assertion blocks at the end of each test.

A single binary to rule them all

We hold the principle that your Loco app is a single binary. Everything is embedded (other than server-side templates and assets which are held on disk on purpose since it's a different use case and are an optional feature): your code, workers, even email templates. You can deploy it to the cloud by having a Docker have just a single binary, or copy it manually to a Raspberry Pi. Having a single binary is magical in a way that it enables a diverse set of use cases.

Getting Started with Loco

Setting Up Your Development Environment

$ cargo install loco-cli
$ cargo install sea-orm-cli

If you need, you can run Postgres and Redis (for background jobs) via Docker. Note myapp_development is specific to an app called myapp which we will soon choose:

$ docker run -d -p 5432:5432 -e POSTGRES_USER=loco -e POSTGRES_DB=myapp_development -e POSTGRES_PASSWORD="loco" postgres:15.3-alpine
$ docker run -p 6379:6379 -d redis redis-server

Creating Your First Loco Project

Run loco new (the loco binary is provided by loco-cli):

$ loco new
βœ” ❯ App name? Β· myapp
? ❯ What would you like to build? β€Ί
  lightweight-service (minimal, only controllers and views)
  Rest API (with DB and user auth)
❯ Saas app (with DB and user auth)
πŸš‚ Loco app generated successfully in:
myapp

See that everything is OK with the doctor command:

$ cd myapp
$ cargo loco doctor
$ cargo loco doctor
    Finished dev [unoptimized + debuginfo] target(s) in 0.32s
     Running `target/debug/myapp-cli doctor`
βœ… SeaORM CLI is installed
βœ… DB connection: success
βœ… Redis connection: success

Finally, start your engines!

$ cargo loco start

                      β–„     β–€
                                 β–€  β–„
                  β–„       β–€     β–„  β–„ β–„β–€
                                    β–„ β–€β–„β–„
                        β–„     β–€    β–€  β–€β–„β–€β–ˆβ–„
                                          β–€β–ˆβ–„
β–„β–„β–„β–„β–„β–„β–„  β–„β–„β–„β–„β–„β–„β–„β–„β–„   β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„ β–„β–„β–„β–„β–„β–„β–„β–„β–„ β–€β–€β–ˆ
 β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ  β–ˆβ–ˆβ–ˆβ–ˆβ–ˆ   β–ˆβ–ˆβ–ˆ β–ˆβ–ˆβ–ˆβ–ˆβ–ˆ   β–ˆβ–ˆβ–ˆ β–ˆβ–ˆβ–ˆβ–ˆβ–ˆ   β–ˆβ–ˆβ–ˆ β–€β–ˆ
 β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ  β–ˆβ–ˆβ–ˆβ–ˆβ–ˆ   β–ˆβ–ˆβ–ˆ β–ˆβ–ˆβ–ˆβ–ˆβ–ˆ   β–€β–€β–€ β–ˆβ–ˆβ–ˆβ–ˆβ–ˆ   β–ˆβ–ˆβ–ˆ β–„β–ˆβ–„
 β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ  β–ˆβ–ˆβ–ˆβ–ˆβ–ˆ   β–ˆβ–ˆβ–ˆ β–ˆβ–ˆβ–ˆβ–ˆβ–ˆ       β–ˆβ–ˆβ–ˆβ–ˆβ–ˆ   β–ˆβ–ˆβ–ˆ β–ˆβ–ˆβ–ˆβ–ˆβ–„
 β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ  β–ˆβ–ˆβ–ˆβ–ˆβ–ˆ   β–ˆβ–ˆβ–ˆ β–ˆβ–ˆβ–ˆβ–ˆβ–ˆ   β–„β–„β–„ β–ˆβ–ˆβ–ˆβ–ˆβ–ˆ   β–ˆβ–ˆβ–ˆ β–ˆβ–ˆβ–ˆβ–ˆβ–ˆ
 β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ  β–ˆβ–ˆβ–ˆβ–ˆβ–ˆ   β–ˆβ–ˆβ–ˆ  β–ˆβ–ˆβ–ˆβ–ˆ   β–ˆβ–ˆβ–ˆ β–ˆβ–ˆβ–ˆβ–ˆβ–ˆ   β–ˆβ–ˆβ–ˆ β–ˆβ–ˆβ–ˆβ–ˆβ–€
   β–€β–€β–€β–ˆβ–ˆβ–„ β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€  β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€  β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€ β–ˆβ–ˆβ–€
       β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€
                https://loco.rs

listening on port 3000

A taste of Loco: Example Features and Techniques

Using the Generator Framework

You can drive all of your development by generating parts of your app. For exapmle loco generate will automatically create your entities and inject them into all the required places in your app.

$ 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"

You can use generate scaffold to create a complete CRUD API based on a model description that you specify on the CLI.

Using authentication

By using the auth::JWT extractor (extractors are extension points in Axum), you can opt-in and have authenticated routes:

async fn add(
    auth: auth::JWT,
    State(ctx): State<AppContext>,
    Json(params): Json<Params>,
) -> Result<Json<CurrentResponse>> {
  // 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)
}

Export user data with Tasks

Tasks are a way to encapsulate a workflow, errand, or operators use cases that need access to a running app's infrastructure such as models and data. One example of a use case is to be able to run a report and export its data, all from the CLI.

Here's how such a task might look like:

// find it in `src/tasks/user_report.rs`
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: &BTreeMap<String, String>) -> 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(())
    }
}

And now you can list it:

$ cargo loco task
user_report		[output a user report]

And running it is simple:

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

Automation with background workers

You can even automate heavy tasks with a distributed background worker. Start by generating it:

$ cargo loco generate worker report_worker

The new worker code looks like this:

#[async_trait]
impl Worker<DownloadWorkerArgs> for DownloadWorker {
    async fn perform(&self, args: DownloadWorkerArgs) -> Result<()> {
        println!("================================================");
        println!("Sending payment report to user {}", args.user_guid);

        // TODO: Some actual work goes here...

        println!("================================================");
        Ok(())
    }
}

Now just code the logic, and run it:

$ cargo loco start --server-and-worker

If you want to run the worker on a separate machine or separate process:

$ cargo loco start --server-and-worker

Rendering Server Side Views

Loco supports Tera as a template engine out of the box.

pub async fn render_home(ViewEngine(v): ViewEngine<TeraView>) -> Result<impl IntoResponse> {
    format::render().view(&v, "home/hello.html", json!({}))
}

Building JSON APIs with Serde

You automatically generated model supports Serde out of the box. Simply return it with format::json to render JSON for your API.

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

Building Chats with WebSockets

Loco supports socketioxide when you enable the channels feature.

Connect your channel:

impl Hooks for App {
    //...
    fn routes(ctx: &AppContext) -> AppRoutes {
        AppRoutes::empty()
        .prefix("/api")
        .add_app_channels(Self::register_channels(ctx))
    }

    fn register_channels(_ctx: &AppContext) -> AppChannels {
        let channels = AppChannels::default();
        channels.register.ns("/", channels::application::on_connect);
        channels
    }

And implement your channel logic:

fn on_connect(socket: SocketRef, Data(data): Data<Value>) {
    info!("Socket.IO connected: {:?} {:?}", socket.ns(), socket.id);
    socket.emit("auth", data).ok();

    socket.on(
        "message",
        |socket: SocketRef, Data::<Value>(data), Bin(bin)| {
            info!("Received event: {:?} {:?}", data, bin);
            socket.bin(bin).emit("message-back", data).ok();
        },
    );

    socket.on(
        "message-with-ack",
        |Data::<Value>(data), ack: AckSender, Bin(bin)| {
            info!("Received event: {:?} {:?}", data, bin);
            ack.bin(bin).send(data).ok();
        },
    );

Seeding Data

Loco has a mini framework for seeding databases with initial data or fixed data, use it from anywhere you like (typically from within a Loco task):

let path = std::path::Path::new("src/fixtures");
db::run_app_seed::<App>(&app_context.db, path).await?;

Piece of Cake Deployment

Run cargo loco deployment and choose your preferred deployment stack from a list.

$ cargo loco generate deployment
? ❯ Choose your deployment β€Ί
❯ Docker
❯ Shuttle
❯ Nginx
...

For example if you choose docker you'll get a automagically generated Dockerfile specially crafted for your app. Or if you choose shuttle, Loco will change your app binary and configuration to fit a 1-command deploy to Shuttle.rs.

Sending Emails

You can send emails without any external service. It includes building your own email templates and sending them out the wire.

Like in Rails, you create mailers:

impl AuthMailer {
    /// When email sending is failed
    pub async fn send_welcome(ctx: &AppContext, user: &users::Model) -> Result<()> {
        Self::mail_template(
            ctx,
            //...
        )
        .await?;

        Ok(())
    }
}

And Loco takes care of the required background jobs to actually perform the send task.

Storage and File Uploads

Loco contains a powerful framework for file uploads and blob storage which supports:

  • Single storage
  • Multiple storage providers
  • Storage strategies and advanced logic for implementing primary/backup redundancy, automatic mirroring and others.

It is available through Context from everywhere:

ctx.storage
    .as_ref()
    .expect("storage")
    .upload(path.as_path(), &content)
    .await?;

Conclusion and Next Steps

The Future of Web Development with Loco

Loco strives to be feature packed, and feature-parity with Rails, at least for the big features. Some other goals for Loco are:

  • Be a one person framework. For those looking to build side projects easily, quickly, and cheap, and later scale those to startups with the same stable, robust, performant codebase.
  • Fatigue-free maintenance. Simple dependencies, single binary, easy deployment.
  • Everywhere possible - driven by tools and powertools. The Loco CLI, generators, test kits for easy testing.
  • Embrace modern, sparkly, Web development practices, but also old and boring practices because boring is simple. This is why Loco supports both JSON API mode with clientside frontend as well as server-side template rendering.

Inviting Feedback and Contributions

Creating a diverse committer team is very important. It allows people of all skill levels coming from any kind of programming language to share ideas and interact. We especially want to see Rubyists and Rails devs contributing ideas and code to Loco.

Take a look at the issues, or come up with your own ideas and scratch your own itch -- we're accepting PRs!.