
My First Experience Building an API with Rust
M. Zakyuddin Munziri
@zakiego
Originally written in Bahasa Indonesia.
Note
This is not a tutorial, but rather contains insights and lessons learned. Many concepts are simplified, and of course, much of the code needs improvement. This is not a best practice guide. Feel free to explore further.
Abstract
After seeing many tweets from Rust enthusiasts, I became interested in trying Rust again. The project I attempted was using Axum as the backend framework and SQLX as the SQL toolkit. Many small things were learned along the way. And of course, much needs to be improved.
The code is still in development and can be viewed at github.com/zakiego/social-axum, and accessed at axum.zakiego.com.
Background
Lately, Rust has been appearing frequently on my timeline. There are at least two main Rust players that keep showing up: @papanberjalan and @mustafasegf.
If you think this is my first time learning Rust, you're wrong. I first learned Rust in late 2021. Did I get it immediately? Of course not.
I don't know how many times I've tried learning Rust and "couldn't get it yet". I have to admit, learning Rust is hard. First, it's hard because the concepts are quite different from other programming languages. Second, I was confused about what project to build.
Three years later, this is finally the first time I feel confident enough to write a Hello World program and create an API that connects directly to a database.
So my message is, if you're trying Rust for the first time and feeling confused, then I congratulate you. You're on the right path. The path of those who are confused.
Preparing the Ingredients
There are several framework options for building a backend: Rocket, Actix, and Axum. I've tried them all. And finally for this project, I followed @mustafasegf's advice to use Axum.
Next is choosing the database connector. There are several options: Diesel, SeaORM, and SQLX. Again, because @papanberjalan often complains about ORMs, I decided to use SQLX, since I'll be working with raw SQL queries.
Start Cooking
In this article, I'll only highlight some lines of code, and much will be simplified. This code was written by someone who is just learning, so there will be many shortcomings that need to be fixed.
It turns out that the structure for building a backend in Rust is not much different from JavaScript. It's similar to Express.js and Fastify. Each route has a path and a handler.
Here's the code.
#[tokio::main]
async fn main() {
let pool = establish_connection().await.unwrap();
let app = Router::new()
.route("/", get(get_all_posts))
.route("/post/create", post(create_post))
.with_state(pool);
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
The server will run on localhost with port 3000.
From the code above, there are two routes: "/" and "/post/create". Each route has its own handler. This means when a path is accessed, it will execute the available handler. For example, if we access "/", it will run the get_all_posts function.
Axum provides several HTTP methods, such as GET, POST, etc. We just need to adjust accordingly.
pool is the database connection. I pass it to Axum through .with_state(pool). This State containing the pool will then be available in every handler, so we don't need to pass it one by one. Note, I don't know if this is a best practice or not.聽
I just remembered, there's a slightly different mental model when developing in Rust versus JavaScript. One of the most noticeable is compile time. If JavaScript is quick, Rust feels (maybe very) slow.
This becomes heavier with no "dev" mode available. So I worked around it with the https://crates.io/crates/cargo-watch crate. Then I added a shortcut in .zshrc/.bashrc:
alias cw="cargo watch -x run"
This shortcut makes it like pnpm dev in Next.js. Every time there's a change in the code, the server will restart automatically.
Moving on, let's look at the code for querying the database.
#[derive(sqlx::FromRow, Serialize)]
pub struct PostSelect {
pub id: Uuid,
pub title: String,
pub content: String,
pub created_at: Optionchrono::NaiveDateTime,
pub username: String,
}
pub async fn get_all_posts(State(pool): State<PgPool>) -> Json<Value> {
let result: Vec<PostSelect> = sqlx::query_as(
"SELECT
p.id,
p.title,
p.content,
p.created_at,
u.username as username
FROM posts p
LEFT JOIN users u ON p.user_id = u.id
ORDER BY p.created_at DESC
",
)
.fetch_all(&pool)
.await
.expect("Failed to fetch posts");
Json(json!({"success": true, "data": result}))
}
The annoying thing about Rust is that every function must have its output defined. Blessed are those who have worked with TypeScript, this will feel lighter.
When returning, I want to return data in JSON format. We can do this by defining -> Json<Value>. Fortunately, Json is a generic. So we don't need to explicitly define what form the returned JSON will take.
Remember, earlier we put pool into a State that can be accessed by all handlers. In this function, we retrieve that pool.
There are several ways to query in SQLX, we can use query, query!, query_as, query_as!, and others. I haven't studied them in depth yet, so far, what's important is that the program runs.
There's one culture that feels different in the Rust world, we'll be forced to read documentation more. In fact, it's not uncommon to read the source code directly. For example, about the query earlier, we can read it at docs.rs.
Again, the skill of reading documentation is a very important skill.
Back to the code, when writing Rust code, you'll often encounter #[derive()], a syntax that I honestly don't know how it works. But for sure, it has some kind of magic to change many things.
An example is serde::Serialize which has the magic to convert a struct into an object. Without that derive, the struct won't be able to take object form. In my heart, this is also strange.
Finally, after all the code runs safely with cargo run, it's time to take off to production by running the command cargo build --release.
Closing
It's satisfying that after three years of learning Rust, I can finally feel more comfortable writing code with it. Although actually, I'm still fighting with the borrow checker all the time.
There's still much to explore and improve. It might be interesting next to write about how I created a Docker image in GitHub Action and then ran it on a VPS. A brief journey can be seen in this tweet.
As an addition, now, every code push I make to GitHub will be directly built and run on the server.
Finally, my advice for friends who choose the path of learning Rust, use the words "can't do it yet" every time you're stuck in learning, don't use the words "can't do it". This is a mental model I keep applying.
As a souvenir, I often share small things about daily programming on my Twitter account @zakiego 馃憢馃徎


