Coming to Rust from Django

As a Day of learning initiative at my job, I decided to try what a simple REST API would look like in Rust. Specifically, coming from Python, how difficult would it be to use a statically typed language that doesn't offer the same flexibility as dynamically typed languages.

It's been long due that I dived into Rust more than your basic Hello World! example.

Choosing the building blocks

Coming from Django it just... does a lot of work for me. From routing, authentication, form validation to ORM and migrations and what not. Having so many extensions published on PyPI is a nice treat, too.

In the Rust ecosystem, I wasn't much aware of what's available, what's the most used, what's the best tool for which job. However, my amazing girlfriend, who uses Rust as her primary language of choice, taught me about some of the libraries or frameworks, so I already knew some names and saw a couple code examples.

Anyway, there's this great website called Are we web yet? Reading the landing page, we see this paragraph:

Rust does not have a dominant framework at the level of Django or Rails. Most Rust frameworks are smaller and modular, similar to Flask or Sinatra. Rust does have a diverse package ecosystem, but you generally have to wire everything up yourself. Expect to put in a little bit of extra set up work to get started. If you are expecting everything bundled up for you, then Rust might not be for you just yet.

Similar to Flask? Great! I know Flask! Maybe it won't be that hard.

I tried to search for something, that would resemble Django or at least Flask. For the web part, I chose Rocket. It seems to have a clean, easy API, so why not? For the database part, I decided on Diesel — it has models, migrations and most importantly a querying API that is kinda like Django's. We'll get to that.

Launching a Rocket

First step: a simple GET request with a plain text response. That cannot be hard.

What I would do in Django:

Configure routes for an app x:

urlpatterns = [
    path('ping', views.ping, name='ping'),
]

Include x's URLs in the project:

urlpatterns = [
    path('admin/', admin.site.urls),
    path('', include('x.urls', namespace='x'))
]

Add a view function:

from django.http import HttpResponse

def ping(request):
    return HttpResponse('pong')

And that would be about it. Now the Rocket equivalent:

My Cargo.toml would look like this:

[dependencies]
rocket = "0.4.9"

main.rs contains a little bit of magic that I had no idea about:

#![feature(proc_macro_hygiene, decl_macro)]

#[macro_use]
extern crate rocket;

mod views;

fn main() {
    rocket::ignite()
        .mount("/", routes![views::ping])
        .launch();
}

Seeing #[macro_use] everywhere... what is it? It doesn't work without it. It has to be before every extern crate according to the docs. And... oh, it's that simple, alright, so we just bring the crate's macros into the scope.

The first line? No idea whatsoever.

In my views/mod.rs I just put this:

#[get("/ping")]
pub fn ping() -> &'static str {
    "pong"
}

And this is very nice, because it looks a lot like Flask:

@app.route('/ping')
def success(name):
   return 'pong'

Find 5 differences. So far, so good. This seems simple enough, there's even less configuration than in Django.

Listing users

Another simple thing to do might be... retrieving some data from a database! Let's do that!

We'll try a very simple model: a user with id and username fields. Again, for illustration, let's start with the Django version.

Django example

First, I'd add a model for the table I am creating:

class User(models.Model):
    username = models.CharField(max_length=64, null=False)

The id field here is added automatically, as a primary key with auto increment. Let Django create the table:

./manage.py makemigrations x
./manage.py migrate

And we'll insert some rows manually.

The view function, the most basic version, will look like this:

def list_users(request):
    users = models.User.objects.values()
    return JsonResponse(list(users), safe=False)

The example is very simple, readable, but yeah, there's some magic that Django does for us (object managers) and just the dynamic nature of Python simplifies things a lot.

Creating models and tables

And now back to Rust. Now since we're putting together building blocks instead of having everything bundled together, it's time to setup the database using diesel_cli. There's a very comprehensive guide on the diesel website.

For this example, I'll be using sqlite3:

diesel setup --database-url test.sqlite3

To create a model, let's create src/models.rs (as per the guide):

use serde::Serialize;
use diesel::*;

#[derive(Queryable, Serialize)]
pub struct User {
    pub id: i32,
    pub username: String
}

I think this is quite straightforward. The docs only derive Queryable, not Serialize, though, but we'll need to serialize this struct into JSON later, so let's do it already now:

[dependencies]
serde = { version = "1.0.126", features = ["derive"] }

Okay, so we've got the model, but unlike Django, Diesel won't create the table itself (or rather the guide doesn't mention it, and I didn't get much further yet). However, diesel provides an interface to create the migrations via diesel migration generate <table_name>. This creates a directory with two files: up.sql and down.sql, one for upgrading the schema, one for downgrading.

For our example, they'll look like this:

-- up.sql

CREATE TABLE users (
    id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
    username VARCHAR(64) NOT NULL
);
CREATE UNIQUE INDEX username_unique ON users(username);
-- down.sql

DROP INDEX username_unique;
DROP TABLE users;

To run them, a simple diesel migration run will do the trick. At this point, we can confirm the newly created schema by running diesel print-schema, the output will look like this:

table! {
    users (id) {
        id -> Integer,
        username -> Text,
    }
}

Which is actually the content of src/schema.rs. This is some macro magic I'm yet too new in Rust to understand.

Wiring up Rocket with Diesel

Luckily, Rocket already comes with a way to use Diesel easily. The documentation resides here.

First, we'll need rocket_contrib crate with features for Diesel:

[dependencies]
rocket_contrib = { version = "0.4.9", features = ["diesel_sqlite_pool"] }

And add some imports to src/main.rs:

#[macro_use]
extern crate diesel;
#[macro_use]
extern crate rocket_contrib;

mod models;
mod schema;

I was pretty confused about the mod usage here, since I don't use the code directly here, yet it somehow didn't work without it.

Second, add a struct that will hold the database connection:

#[database("test_db")]
pub struct TestDbConn(diesel::SqliteConnection);

fn main() {
    // ...
}

And finally, add the connection to the global state of Rocket:

fn main() {
    rocket::ignite()
        .attach(TestDbConn::fairing())
        // .mount("/", routes![])
        .launch();
}

Adding a view

Somehow, rust-analyzer (which is an amazing project btw) was adding imports for me behind my back and it ended up with this:

// For returning a Json response
use rocket_contrib::json::Json;
// DB connection and user model
use crate::{TestDbConn, models::User};
// Diesel magic
use crate::schema::users::dsl::*;
use diesel::{QueryDsl, RunQueryDsl};

The view itself looks fairly easy:

#[get("/users")]
pub fn list_users(conn: TestDbConn) -> Json<Vec<User>> {
    let result = users.load::<User>(&*conn).unwrap();

    Json(result)
}

One thing that is sometimes an obstacle for me, coming from Python, is Rust's turbofish syntax. I have to tell the compiler to load the results into the User struct using ::<User>. I mean, it's understandable and makes sense, but when you code in Python every day, you kinda forget you need it.

The dereference of the connection (&*conn) just means, that conn: TestDbConn is actually wrapping the underlying Diesel connection diesel::SqliteConnection. However, the error produced by rustc didn't seem so straightforward to me when I first saw it:

  --> src/users/mod.rs:10:37
   |
10 |     let result = users.load::<User>(&conn).unwrap();
   |                                     ^^^^^ the trait `diesel::Connection` is not implemented for `TestDbConn`
   |
   = note: required because of the requirements on the impl of `LoadQuery<TestDbConn, User>` for `table`

But basically that means, that we're passing TestDbConn into the load method, rather than something that implements the diesel::Connection trait.

Getting specific users

The last example for this blog post is about getting a specific user by ID.

Now, I don't usually use Django for APIs (I think Flask is better for that), and if you're gonna use Django, it's better to use django REST framework anyway. But man, did I have to fight Django:

from django.core import serializers

def get_user(request, user_id):
    user = get_object_or_404(models.User, id=user_id)
    return HttpResponse(serializers.serialize('json', [user]), content_type='application/json')

Honestly, Rust was much more pleasant for this simple example:

#[get("/users/<user_id>")]
pub fn get_user(conn: TestDbConn, user_id: i32) -> Option<Json<User>> {
    let result = users.find(user_id).first::<User>(&*conn);

    match result {
        Ok(user) => Some(Json(user)),
        Err(_e) => None
    }
}

And Rocket ensures that Some results in a Json response with that object and None returns a 404. Plus we get all the benefits from static typing :).

Conclusion

So this was fun actually! I learned that Rust is not to be feared, we had some arguments and misunderstandings between me and rustc, but ultimately it was just rustc telling me that I'm doing something I shouldn't be. Python will also tell me that, but at runtime instead of compile time.

I realize there's many frameworks for web stuff in both languages, however I really really like Rocket so far. Diesel is not bad either, but hopefully I'll get to try sqlx soon, too.

Rocket seems pretty simple to use. And it's kind of a "batteries included" framework. I also have some very basic experience with Tide and Warp, but hey, I'm coming from Django and Flask, I need something easy to start off.

The issues I ran (as a beginner) into aren't many, but here's the list:

  • Imports: extern crate, use and that macro magic, plus not knowing where to find what to import
  • Module system: using mod and crate:: specifiers to import modules instead of import mymod
  • Forgetting about turbofish
  • Not knowing what type mismatch sin I committed, because instead of expected String, got i32, I get something like the trait X is not implemented for Y, which is kinda confusing in the beginning

Overall my impression is that I'll be very happy to try more and more of Rust. This blog post was my first independent, possibly-useful-in-the-future attempt in Rust, beyond your typical Hello, World! example. Hopefully it'll encourage someone else to try Rust :).