Building a REST API in Rust as a Beginner: What Nobody Tells You
I decided to build a REST API in Rust. Not because I needed to — I could have done it in Node or Go in an afternoon — but because I wanted…
Building a REST API in Rust as a Beginner: What Nobody Tells You
I decided to build a REST API in Rust. Not because I needed to — I could have done it in Node or Go in an afternoon — but because I wanted to understand what Rust actually feels like when you’re building something real, not just following a “hello world” tutorial.
What followed was a humbling, frustrating, and ultimately satisfying experience. This post is an honest account of every wall I hit, every error I misread, and every “oh, THAT’S what that means” moment along the way.
Part 1 — The Model Mess
The first thing I wrote was models.rs. Seemed simple enough — a struct, two enums, done.
Here’s the first broken version:
enum Status { // not public
Pending,
InProgress,
Blocked,
Completed
}
enum Priority {
Low,
Medium,
High
}
pub struct Todos {
pub id: String,
pub title: String,
pub description: String,
pub status: Status, // private type in public struct — error
pub priority: Priority, // same problem
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub deleted: bool
}
pub struct CreateTodo {
pub title: String,
pub description: String,
pub status: Status,
pub priority: Priority
}
Three problems right here:
StatusandPrioritywere private. Using a private type in a public struct's fields is a visibility error in Rust.CreateTodohad no#[derive(Deserialize)]. It's used as a JSON request body in Axum — which requiresDeserialize. The compiler would rightfully refuse.Todos::new()only took 4 params — but when reading from the database I needed to hydrate all 8 fields includingid,created_at,updated_at, anddeleted. I had one constructor doing two different jobs.
The fix was to make the enums public, add derives to CreateTodo, and split constructors into two clear responsibilities:
#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, Copy,
PartialEq, Eq, Hash, EnumString, Display)]
pub enum Status {
Pending,
InProgress,
Blocked,
Completed
}
impl Todos {
// for creating new todos — generates id, timestamps automatically
pub fn new(title: String, description: String, status: Status, priority: Priority) -> Todos {
Todos {
id: uuid::Uuid::new_v4().to_string(),
title,
description,
status,
priority,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
deleted: false,
}
}
// for hydrating from database rows — accepts raw strings for timestamps
pub fn from_row(
id: String,
title: String,
description: String,
status: Status,
priority: Priority,
created_at: &str, // raw string from SQLite
updated_at: &str,
deleted: bool,
) -> Todos {
let parse_dt = |s: &str| {
DateTime::from_naive_utc_and_offset(
NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S")
.unwrap_or_default(),
Utc,
)
};
Todos {
id, title, description, status, priority,
created_at: parse_dt(created_at),
updated_at: parse_dt(updated_at),
deleted,
}
}
}
Notice from_row accepts &str for the timestamp fields and parses them internally. That leads perfectly into the next headache.
Part 2 — SQLite’s Dirty Secret
I had declared my table like this:
CREATE TABLE todo (
id UUID,
created_at DATETIME,
updated_at DATETIME,
deleted BOOLEAN,
...
);
Looks reasonable. DATETIME column, so when I read it back I'll get a DateTime, right?
Wrong.
I ran this query on my actual database:
SELECT typeof(created_at), typeof(updated_at), typeof(deleted) FROM todo LIMIT 1;
Output:
text|text|integer
SQLite stored my DATETIME as text and my BOOLEAN as integer. The column type declarations are essentially ignored. SQLite uses what it calls "Type Affinity" — a soft suggestion, not an enforcement. Whatever Rust code gives it, that's what gets stored.
Since my insert code was:
let created_str = todo.created_at.format("%Y-%m-%d %H:%M:%S").to_string();
statement.bind((":created_at", created_str.as_str())).unwrap();
…I was binding a &str, so SQLite stored a string. When reading it back, I got that same string back. Which is why from_row accepts &str for timestamps and parses manually — there's no other way. The sqlite crate's Bindable trait only supports &str, i64, f64, &[u8], and (). DateTime<Utc> is simply not on that list.
The full round-trip looks like this:
Write: DateTime<Utc> → .format(...) → TEXT in SQLite
Read: TEXT in SQLite → parse_from_str(...) → DateTime<Utc>
Compare this to PostgreSQL or MySQL where TIMESTAMP is a real type. SQLite just doesn't work that way. Once I understood this, the whole parsing chain made complete sense.
Part 3 — Axum State Management Bit Me Hard
Here’s the bug that produced the most incomprehensible error message of the whole project. My router setup looked like this:
let app = Router::new()
.route("/todo/", get(get_todos_route))
.route("/todo/", post(create_todo_route)
.with_state(db)); // ← this is on post(...), not on Router
The error I got was a wall of trait bound failures about S::Future: Send with a suggestion to check a .long-type-13939473212512119779.txt file. Absolutely nothing pointing to the actual problem.
The fix was just moving .with_state(db) to chain on the Router, not on the handler:
let app = Router::new()
.route("/", get(|| async { "My Todo App" }))
.route("/todo/", get(get_todos_route))
.route("/todo/", post(create_todo_route))
.route("/todo/{id}", get(get_todo_route))
.route("/todo/{id}", delete(delete_todo_route))
.route("/todo/{id}", put(update_todo_route))
.route("/todo/{id}", patch(patch_todo_route))
.with_state(db); // ← on the Router, last
One line moved, error vanished. Rust’s error messages are usually excellent, but when Axum’s generics go wrong, they collapse into near-unreadable noise.
Part 4 — Match Arms Must Agree on Types
In Axum, route handlers return impl IntoResponse. My first attempt at returning status codes:
match create_todo {
Ok(todo) => (StatusCode::CREATED, Json(todo)), // (StatusCode, Json<Todos>)
Err(_) => (StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "message": "error" }))), // (StatusCode, Json<Value>)
}
Json<Todos> and Json<serde_json::Value> are different types. Rust requires all match arms to return the same type — it won't coerce them.
The fix: wrap everything in json!() macro which always returns serde_json::Value regardless of content, making both arms the same type:
match create_todo {
Ok(todo) => (
StatusCode::CREATED,
Json(json!({ "data": todo }))
),
Err(_) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "message": "something went wrong" }))
),
}
This pattern — wrapping all responses in json!() — became consistent across every route:
pub async fn get_todos_route(State(db): State<DbState>) -> impl IntoResponse {
match get_todos(db).await {
Ok(todos) => (StatusCode::OK, Json(json!({ "data": todos }))),
Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "message": "something went wrong" }))),
}
}
Part 5 — PATCH the Right Way (Dynamic Queries)
This was the most interesting engineering problem. A PATCH request should only update the fields the client sends. If they send { "status": "Completed" }, only status should change.
My first instinct was to write a separate match for each field:
let mut query = String::from("UPDATE todo SET ");
match todo.title {
Some(title) => query.push_str("title = :title"),
None => {}
}
match todo.description {
Some(_) => query.push_str(", description = :description"), // need comma logic here
None => {}
}
// ... repeat for every field
The problem: you have to manually track whether you’ve already added a field to know whether to prepend a comma. It gets ugly fast.
The clean solution: collect SET clauses and bind values into separate vectors, then join:
pub async fn patch_todo(db: DbState, id: String, todo: PatchTodo) -> Result<Todos, sqlite::Error> {
let mut set_clauses: Vec<&str> = Vec::new();
let mut title_val: Option<String> = None;
let mut status_val: Option<String> = None;
// ... etc
if let Some(title) = todo.title {
set_clauses.push("title = :title");
title_val = Some(title);
}
if let Some(status) = todo.status {
set_clauses.push("status = :status");
status_val = Some(status.to_string());
}
// guard: reject empty PATCH body
if set_clauses.is_empty() {
return Err(sqlite::Error {
code: None,
message: Some("No fields provided to update".to_string()),
});
}
// always bump updated_at
set_clauses.push("updated_at = :updated_at");
// join builds "title = :title, status = :status, updated_at = :updated_at"
let query = format!("UPDATE todo SET {} WHERE id = :id", set_clauses.join(", "));
let connection = db.lock().unwrap();
let mut statement = connection.prepare(query.as_str()).unwrap();
// only bind what was provided - missing params never appear in the query
if let Some(ref v) = title_val { statement.bind((":title", v.as_str())).unwrap(); }
if let Some(ref v) = status_val { statement.bind((":status", v.as_str())).unwrap(); }
// ...
statement.next().unwrap();
// fetch and return updated row...
}
The key insight: if title is None, :title never appears in the generated query string — so SQLite never expects a binding for it. No binding needed, no error.
The Full Project Structure
src/
├── main.rs # Router setup, server boot
├── db.rs # All database queries (raw sqlite)
├── models.rs # Structs and enums (Todos, CreateTodo, PutTodo, PatchTodo)
└── routes.rs # Route handlers (one per endpoint)
All endpoints:
MethodEndpointDescriptionGET/Health checkGET/todo/Get all todosPOST/todo/Create a todoGET/todo/{id}Get a single todoPUT/todo/{id}Full replacePATCH/todo/{id}Partial updateDELETE/todo/{id}Delete a todo
What I’d Do Differently
1. Use sqlx instead of sqlite The raw sqlite crate requires manually binding every parameter, manually reading every column, and manually converting types. sqlx gives you compile-time query verification, async-native queries, and connection pooling. I avoided it to feel the friction — and I felt it.
2. Proper error types Every Err arm currently returns a generic 500. A real API needs a custom error type implementing IntoResponse that maps different errors to different status codes (404 for not found, 400 for validation errors, 500 for DB failures).
3. Input validation The validator crate lets you annotate struct fields with rules:
#[derive(Validate)]
pub struct CreateTodo {
#[validate(length(min = 1, max = 100))]
pub title: String,
// ...
}
Currently nothing stops a client from sending an empty title.
4. Pagination and filtering GET /todo/ returns every row. In production you'd want:
GET /todo/?page=1&limit=10&status=Pending&priority=High
5. Soft deletes The deleted column already exists in the schema. The groundwork is there — just need DELETE to set deleted = 1 instead of removing the row, and filter it out of GET /todo/ results.
Final Thoughts
Rust is unforgiving in a way that initially feels hostile but ultimately forces you to understand what your code is doing at every step. The type system caught bugs I wouldn’t have noticed until runtime in any other language.
The things that tripped me up — private enums, .with_state() placement, match arm type unification — aren't hard concepts once you understand them. They're just different from what most languages do, and the error messages don't always point you to the right place.
If you’re coming from Node, Go, or Python and thinking about Rust for a backend project: the barrier is real but not insurmountable. Start small, read the errors carefully (most of the time they’re excellent), and don’t skip the fundamentals.
Source code: github.com/himanshubohra13/rust-rest-api
메타데이터
- post_id
- e4bf8c6ea4fd
- slug
- building-a-rest-api-in-rust-as-a-beginner-what-nobody-tells-you-e4bf8c6ea4fd
- url
- https://medium.com/@himanshubohra206/building-a-rest-api-in-rust-as-a-beginner-what-nobody-tells-you-e4bf8c6ea4fd
- canonical_url
- https://medium.com/@himanshubohra206/building-a-rest-api-in-rust-as-a-beginner-what-nobody-tells-you-e4bf8c6ea4fd
- author_url
- https://medium.com/@himanshubohra206
- status
- ok
- fetched_at
- 2026-07-08 23:38:59