Introduction to RESTful API Development with Rust for Node.js Developers
In this tutorial we will build a simple RESTful API in Rust using Axum. It is geared toward experienced Node.js developers who want to explore Rust for web development. We will recreate the Colors API from my earlier TypeScript tutorial. Although this is not intended as a general introduction to the Rust language, it is structured to be easy to follow for those new to the language.
Setting Up Rust for Development
Before we begin, we have to setup our development environment. If you are new to Rust, I recommend starting with my earlier tutorial, which walks you through creating a Rust project in VS Code from scratch, including IntelliSense, automatic code formatting, and linting, which are not covered here.
Start by creating the project folder.
mkdir colors-api
cd colors-api
Initialize a new Cargo package.
cargo init
This creates a minimal Rust project with the following structure:
colors-api/
├── Cargo.toml
└── src/
└── main.rs
We can now install our dependencies. In Rust, the equivalent of npm install is cargo add, which writes dependencies to Cargo.toml and fetches crates (and their dependencies). Cargo also generates a lock‑file Cargo.lock, but it is only updated during cargo build (or whenever compilation is triggered).
Add the web framework, Axum, which provides routing, request extraction, and response handling, similar to Express in Node.js.
cargo add axum
Add the async runtime, tokio. Rust does not ship with a built‑in event loop and thread pool, unlike Node.js, so we need to provide one ourselves.
cargo add tokio --features full
Add serde for JSON serialization and deserialization, so we can easily convert Rust structs to and from JSON.
cargo add serde --features derive
cargo add serde_json
After adding dependencies your Cargo.toml should resemble the following:
[package]
name = "colors-api"
version = "0.1.0"
edition = "2024"
[dependencies]
axum = "0.8.8"
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149"
tokio = { version = "1.49.0", features = ["full"] }
Sharing Data Safely in Rust
Before diving into code, let’s explain some key differences between Node.js/Express and Rust/Axum. Node.js uses a garbage collector to periodically clean up memory, while Rust frees memory when values go out of scope, enforcing safety at compile time. As a consequence, creating and sharing data in Rust is more involved than in JavaScript, because the compiler must track when memory can be safely freed.
In Node.js we could expose shared data like this:
export const colors = ["RED", "GREEN", "BLUE"];
Because Node.js runs JavaScript on a single thread, we can import colors and use it across our routes without risking two parts of the code modifying the array simultaneously. No parts of our code ever run in parallel.
Rust is also single-threaded by default, but when using an async runtime like tokio, work can be spread across multiple threads. This means several HTTP requests may run concurrently and any shared data between them must be protected, typically with a Mutex.
Moreover, Rust ownership rules require that a value in memory has exactly one owner and cannot be shared arbitrarily as in JavaScript. When data needs to be shared between threads, it is wrapped in an Arc (Atomic Reference-Counted) instance, which tracks how many references exist and frees the memory safely once the last reference is dropped.
To create the same colors array in Rust, we could write:
use std::sync::{Arc, Mutex};
let colors = Arc::new(Mutex::new(vec![
String::from("RED"),
String::from("GREEN"),
String::from("BLUE"),
]));
useworks like import in JavaScript.stdis Rust’s standard library, which is included in every project by default.- The
::operator is used to access associated items (similar to class static members) within types or modules, whereas.is used to access fields or methods on instances.
Rust’s syntax can feel complex, but the good news is that it enforces memory safety at compile time and won’t compile programs that use memory unsafely, giving you more confidence than with other compiled languages, while still offering similar performance benefits.
Building a Rust API Server
As in a Node.js/Express project, we will organize the code into routes → handlers (controllers) → services, to separate concerns and make testing easier. Our project’s directory structure will be as follows:
colors-api/
├── Cargo.toml
└── src/
├── routes/ # Route definitions
│ └── colors.rs
├── handlers/ # Request handlers (controllers)
│ └── colors.rs
├── services/ # Business logic
│ └── colors.rs
├── state.rs # Shared application state
├── models.rs # Request/response models
├── app.rs # Axum app
└── main.rs # Entry point
We will define our shared data store in src/state.rs.
use std::sync::{Arc, Mutex};
pub type ColorsData = Arc<Mutex<Vec<String>>>;
#[derive(Clone)]
pub struct AppState {
pub colors: ColorsData,
}
AppStateis a struct (similar to a class) that holds a single field,colors.- Everything in Rust is private by default, so we mark the struct and its field with
pub(similar to export) to make them accessible from other modules.
AppState will be passed to Axum for use in our routes, but Axum expects the state to implement a clone() method. Structs only contain data by default, but rather than writing the method manually in an impl block, we attach the attribute #[derive(Clone)]. The #[...] syntax is used for giving instructions to the compiler, and derive(Clone) is a built-in macro that automatically implements the Clone trait (like an interface) for AppState.
So what does clone() do? It returns a new AppState instance whose colors field is also cloned. Since colors is an Arc, clone() creates a new Arc instance pointing to the same Vec<String> (array of strings) and increments the reference count. Axum calls clone() automatically each time it passes AppState to a request handler, allowing Rust to track how many references to the colors data exist. When the reference count reaches zero, the data is automatically freed.
Models that represent the request and response payloads are defined in src/models.rs.
use serde::{Deserialize, Serialize};
#[derive(Serialize)]
pub struct ColorsResponse {
pub colors: Vec<String>,
}
#[derive(Deserialize)]
pub struct AddColorRequest {
pub color: String,
}
#[derive(Deserialize)] automatically implements the serde::Deserialize trait for AddColorRequest, allowing Axum to safely parse the incoming JSON body and validate it, while #[derive(Serialize)] enables conversion to JSON for ColorsResponse.
We will define our services in src/services/colors.rs.
use crate::state::ColorsData;
pub fn get_colors(colors: &ColorsData) -> Vec<String> {
colors.lock().unwrap().clone()
}
pub fn add_color(colors: &ColorsData, color: String) -> Vec<String> {
let mut colors = colors.lock().unwrap();
colors.push(color.to_uppercase());
colors.clone()
}
use crate::tells the compiler to look for the module relative to the crate root (the directory containing main.rs).add_color()returnscolors.clone()since the value of the last expression in a Rust function is automatically returned if it does not end with a semicolon.
In Rust, most operations that can fail return a Result type, which is an enum that can be either Ok(value) for success or Err(error) for failure. Calling .unwrap() extracts the value if it is Ok or will panic (terminate the program) if it is Err.
Handlers (controllers) are defined in src/handlers/colors.rs.
use axum::{Json, extract::State, http::StatusCode};
use crate::{
models::{AddColorRequest, ColorsResponse},
services::colors as color_service,
state::AppState,
};
pub async fn get_colors(State(state): State<AppState>) -> Json<ColorsResponse> {
let colors = color_service::get_colors(&state.colors);
Json(ColorsResponse { colors })
}
pub async fn add_color(
State(state): State<AppState>,
Json(payload): Json<AddColorRequest>,
) -> (StatusCode, Json<ColorsResponse>) {
let colors = color_service::add_color(&state.colors, payload.color);
(StatusCode::CREATED, Json(ColorsResponse { colors }))
}
Because of how we configured the app, all handlers receive a state of type State<AppState>. The unfamiliar part is State(state). State is an Axum extractor, and the simplest way to think of it in TypeScript terms is destructuring: get_colors([state]: State<AppState>). Similarly, in add_color what we are actually doing is: add_color([state]: State<AppState>, [payload]: Json<AddColorRequest>).
Extractors applied to function parameters are structs used for destructuring and they don’t perform anything special at runtime themselves. They mainly tell the compiler that this function accepts an instance of type Json<AddColorRequest>, but we want to work with its inner value directly. Some extractors, like JSON, have methods for parsing input, and Axum automatically calls these for us.
The return value of the handler is the response body returned to the client. By default, Axum sets the status to 200, but it can be overridden by returning a tuple: (StatusCode::CREATED, Json(...)).
In src/routes/colors.rs we define our routes.
use axum::{
Router,
routing::{get, post},
};
use crate::{
handlers::colors::{add_color, get_colors},
state::AppState,
};
pub fn router() -> Router<AppState> {
Router::new()
.route("/", get(get_colors))
.route("/", post(add_color))
}
In src/app.rs we set up our Axum application.
use axum::Router;
use crate::{routes, state::AppState};
pub fn create_app(state: AppState) -> Router {
Router::new()
.nest("/colors", routes::colors::router())
.with_state(state)
}
Finally, the program’s entry point is defined in src/main.rs.
mod app;
mod models;
mod state;
mod routes {
pub mod colors;
}
mod handlers {
pub mod colors;
}
mod services {
pub mod colors;
}
use std::sync::{Arc, Mutex};
use tokio::net::TcpListener;
use state::AppState;
const DEFAULT_HOST: &str = "127.0.0.1";
const DEFAULT_PORT: u16 = 3000;
#[tokio::main]
async fn main() {
let state = AppState {
colors: Arc::new(Mutex::new(vec![
String::from("RED"),
String::from("GREEN"),
String::from("BLUE"),
])),
};
let app = app::create_app(state);
let listener = TcpListener::bind((DEFAULT_HOST, DEFAULT_PORT))
.await
.unwrap();
println!(
"Server listening on http://{}:{}",
DEFAULT_HOST, DEFAULT_PORT
);
axum::serve(listener, app).await.unwrap();
}
#[tokio::main] is what allows Rust to run async tasks using an event loop. By default, tokio creates a thread pool, unlike Node.js, which runs JavaScript on a single thread (though Node.js also uses a background thread pool for I/O). There is an option to configure tokio to run in a single thread, but then both the event loop and all tasks, including I/O, would run on one thread, which is usually not ideal for a server.
The program can be compiled and run with cargo run.
cargo run
...
Server listening on http://127.0.0.1:3000
Testing the Rust API Server
In this section, we will look at integration tests. Unit tests are important too, but usually easier to write, and to keep this tutorial concise we won’t cover them here.
Integration tests are placed outside our crate, compile separately, and use our crate as an external library.
colors-api/
├── Cargo.toml
└── src/
├── ...
├── lib.rs # Library crate
└── main.rs
└── tests/
└── colors_api.rs
But first, we need to convert our modules into a library crate so they can be accessed by the external tests. This is done by creating a src/lib.rs file and declaring all of our modules there.
pub mod app;
pub mod state;
mod models;
mod routes {
pub mod colors;
}
mod handlers {
pub mod colors;
}
mod services {
pub mod colors;
}
src/main.rs will remain outside the library crate, so it should be modified to call functions from our new library, instead of defining everything inline. During compilation, our library crate is compiled separately and then statically linked into the main binary. Replace the top part of src/main.rs with:
use std::sync::{Arc, Mutex};
use tokio::net::TcpListener;
use colors_api::app;
use colors_api::state::AppState;
const DEFAULT_HOST: &str = "127.0.0.1";
const DEFAULT_PORT: u16 = 3000;
#[tokio::main]
async fn main() {
...
}
We now use our modules from the colors_api library we created. By default, the library takes its name from the package name in Cargo.toml, which is colors-api, and if the name contains any dashes these are replaced with underscores in the code.
We also need to add tower as a development dependency, since it provides the ServiceExt trait, which adds testing helper methods to our Axum router.
cargo add --dev tower
After adding tower your Cargo.toml should look like this:
[package]
name = "colors-api"
version = "0.1.0"
edition = "2024"
[dependencies]
...
[dev-dependencies]
tower = "0.5.3"
We will write our integration tests in tests/colors_api.rs.
use std::sync::{Arc, Mutex};
use axum::body::{Body, to_bytes};
use axum::http::{Request, Response, StatusCode};
use serde::Deserialize;
use tower::ServiceExt;
use colors_api::{app::create_app, state::AppState};
#[derive(Deserialize)]
struct ColorsResponse {
colors: Vec<String>,
}
#[tokio::test]
async fn get_colors_works() {
let state = AppState {
colors: Arc::new(Mutex::new(vec![
String::from("RED"),
String::from("GREEN"),
String::from("BLUE"),
])),
};
let app = create_app(state);
let response = app.oneshot(get("/colors")).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body: ColorsResponse = json_body(response).await;
assert_eq!(body.colors, vec!["RED", "GREEN", "BLUE"]);
}
#[tokio::test]
async fn post_color_works() {
let state = AppState {
colors: Arc::new(Mutex::new(vec![
String::from("RED"),
String::from("GREEN"),
])),
};
let app = create_app(state);
let response = app
.clone()
.oneshot(post_json("/colors", serde_json::json!({ "color": "BLUE" })))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::CREATED);
let body: ColorsResponse = json_body(response).await;
assert_eq!(body.colors, vec!["RED", "GREEN", "BLUE"]);
}
// Test helpers
fn get(uri: &str) -> Request<Body> {
Request::builder().uri(uri).body(Body::empty()).unwrap()
}
fn post_json(uri: &str, json: serde_json::Value) -> Request<Body> {
Request::builder()
.method("POST")
.uri(uri)
.header("content-type", "application/json")
.body(Body::from(json.to_string()))
.unwrap()
}
async fn json_body<T: serde::de::DeserializeOwned>(
response: Response<Body>,
) -> T {
let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap();
serde_json::from_slice(&bytes).unwrap()
}
The file contains two tests, both annotated with #[tokio::test], along with a few helper functions to reduce code duplication. The oneshot() method comes from the tower::ServiceExt trait and allows us to test our router. Most of the remaining code should look familiar by now. The only new concept introduced here is the use of generics in json_body, which is a helper function for parsing JSON responses.
To run the tests, use cargo test.
cargo test
...
running 2 tests
test get_colors_works ... ok
test post_color_works ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
...
You can find the full code in my GitHub repository. To dive deeper into Rust, the official Rust book is an excellent resource.
