By Kris Black | Published on 1/30/2025

Building Modern APIs with Rust: A Complete Setup Guide

Rust has become increasingly popular for building high-performance web services. In this guide, we'll walk through setting up a complete Rust API application using Actix-web, one of the fastest web frameworks available.

Prerequisites

  • Rust installed (via rustup)
  • Cargo (Rust's package manager)
  • A code editor (VS Code recommended) with the rust-analyzer extension:
    • Install from VS Code marketplace: search for "rust-analyzer"
    • This provides real-time error checking, code completion, and inline hints
  • A Supabase account (free tier works fine)

1. Project Setup

First, let's create a new Rust project:

cargo new rust-api-demo
cd rust-api-demo

Update your Cargo.toml with the necessary dependencies:

[package]
name = "rust-api-demo"
version = "0.1.0"
edition = "2021"

[dependencies]
actix-web = "4.4.0"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
tokio = { version = "1.0", features = ["full"] }
env_logger = "0.10.0"
postgrest = "1.6"
reqwest = { version = "0.11", features = ["json"] }
dotenv = "0.15"

2. Basic API Structure

Replace the contents of src/main.rs with this basic API structure:

use actix_web::{web, App, HttpResponse, HttpServer, Responder};
use serde::{Deserialize, Serialize};
use dotenv::dotenv;
use std::env;
use reqwest::Client;

#[derive(Serialize, Deserialize)]
struct Task {
    id: Option<i64>,
    title: String,
    completed: bool,
}

struct AppState {
    supabase_url: String,
    supabase_key: String,
    client: Client,
}

async fn get_tasks(data: web::Data<AppState>) -> impl Responder {
    let client = &data.client;
    let url = format!("{}/rest/v1/tasks", data.supabase_url);

    let response = client
        .get(&url)
        .header("apikey", &data.supabase_key)
        .header("Authorization", format!("Bearer {}", &data.supabase_key))
        .send()
        .await;

    match response {
        Ok(res) => {
            match res.json::<Vec<Task>>().await {
                Ok(tasks) => HttpResponse::Ok().json(tasks),
                Err(_) => HttpResponse::InternalServerError().finish(),
            }
        }
        Err(_) => HttpResponse::InternalServerError().finish(),
    }
}

async fn create_task(
    data: web::Data<AppState>,
    task: web::Json<Task>
) -> impl Responder {
    let client = &data.client;
    let url = format!("{}/rest/v1/tasks", data.supabase_url);

    let response = client
        .post(&url)
        .header("apikey", &data.supabase_key)
        .header("Authorization", format!("Bearer {}", &data.supabase_key))
        .header("Content-Type", "application/json")
        .header("Prefer", "return=minimal")
        .json(&task.0)
        .send()
        .await;

    match response {
        Ok(_) => HttpResponse::Created().json(task.0),
        Err(_) => HttpResponse::InternalServerError().finish(),
    }
}

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    dotenv().ok();
    env_logger::init();

    let supabase_url = env::var("SUPABASE_URL")
        .expect("SUPABASE_URL must be set");
    let supabase_key = env::var("SUPABASE_KEY")
        .expect("SUPABASE_KEY must be set");

    let client = Client::new();
    let app_state = web::Data::new(AppState {
        supabase_url,
        supabase_key,
        client,
    });

    HttpServer::new(move || {
        App::new()
            .app_data(app_state.clone())
            .route("/tasks", web::get().to(get_tasks))
            .route("/tasks", web::post().to(create_task))
    })
    .bind("127.0.0.1:8080")?
    .run()
    .await
}

3. Running the Application

Start your API server with:

cargo run

4. Testing the Endpoints

You can test the API using curl:

# Get all tasks
curl http://localhost:8080/tasks

# Create a new task
curl -X POST http://localhost:8080/tasks     -H "Content-Type: application/json"     -d '{"id": 3, "title": "Test API", "completed": false}'

Adding More Features

Let's add error handling and middleware:

use actix_web::{middleware, Error, Result};

// Custom error handling
#[derive(Debug, Serialize)]
struct ErrorResponse {
    message: String,
}

async fn handle_task_error(err: Error) -> HttpResponse {
    HttpResponse::InternalServerError().json(ErrorResponse {
        message: err.to_string(),
    })
}

// Add this to your App builder
App::new()
    .wrap(middleware::Logger::default())
    .app_data(web::JsonConfig::default().error_handler(|err, _| {
        Error::from(handle_task_error(err))
    }))
    .route("/tasks", web::get().to(get_tasks))
    .route("/tasks", web::post().to(create_task))

Best Practices

  • Use proper error handling and custom error types
  • Implement logging for debugging and monitoring
  • Structure your code into modules for better organization
  • Add input validation for your endpoints
  • Use environment variables for configuration

Next Steps

To build upon this foundation, consider:

  • Adding database integration (e.g., with SQLx for PostgreSQL)
  • Implementing authentication and authorization
  • Adding OpenAPI/Swagger documentation
  • Setting up testing infrastructure
  • Implementing CORS middleware for web clients

5. Integrating with Supabase

Let's add Supabase integration to store our tasks in a PostgreSQL database:

First, update your Cargo.toml with Supabase dependencies:

[dependencies]
# ... existing dependencies ...
postgrest = "1.6"
reqwest = { version = "0.11", features = ["json"] }
dotenv = "0.15"

Create a .env file in your project root:

SUPABASE_URL=your_supabase_project_url
SUPABASE_KEY=your_supabase_anon_key

Create the tasks table in your Supabase dashboard:

-- Run this in Supabase SQL editor
create table tasks (
    id bigint generated by default as identity primary key,
    title text not null,
    completed boolean default false,
    created_at timestamp with time zone default timezone('utc'::text, now())
);

-- Add row level security
alter table tasks enable row level security;

-- Allow anonymous access for demo (adjust for production)
create policy "Allow anonymous access"
on tasks for all
to anon
using (true)
with check (true);

Update your main.rs to use Supabase:

use actix_web::{web, App, HttpResponse, HttpServer, Responder};
use serde::{Deserialize, Serialize};
use dotenv::dotenv;
use std::env;
use reqwest::Client;

#[derive(Serialize, Deserialize)]
struct Task {
    id: Option<i64>,
    title: String,
    completed: bool,
}

struct AppState {
    supabase_url: String,
    supabase_key: String,
    client: Client,
}

async fn get_tasks(data: web::Data<AppState>) -> impl Responder {
    let client = &data.client;
    let url = format!("{}/rest/v1/tasks", data.supabase_url);

    let response = client
        .get(&url)
        .header("apikey", &data.supabase_key)
        .header("Authorization", format!("Bearer {}", &data.supabase_key))
        .send()
        .await;

    match response {
        Ok(res) => {
            match res.json::<Vec<Task>>().await {
                Ok(tasks) => HttpResponse::Ok().json(tasks),
                Err(_) => HttpResponse::InternalServerError().finish(),
            }
        }
        Err(_) => HttpResponse::InternalServerError().finish(),
    }
}

async fn create_task(
    data: web::Data<AppState>,
    task: web::Json<Task>
) -> impl Responder {
    let client = &data.client;
    let url = format!("{}/rest/v1/tasks", data.supabase_url);

    let response = client
        .post(&url)
        .header("apikey", &data.supabase_key)
        .header("Authorization", format!("Bearer {}", &data.supabase_key))
        .header("Content-Type", "application/json")
        .header("Prefer", "return=minimal")
        .json(&task.0)
        .send()
        .await;

    match response {
        Ok(_) => HttpResponse::Created().json(task.0),
        Err(_) => HttpResponse::InternalServerError().finish(),
    }
}

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    dotenv().ok();
    env_logger::init();

    let supabase_url = env::var("SUPABASE_URL")
        .expect("SUPABASE_URL must be set");
    let supabase_key = env::var("SUPABASE_KEY")
        .expect("SUPABASE_KEY must be set");

    let client = Client::new();
    let app_state = web::Data::new(AppState {
        supabase_url,
        supabase_key,
        client,
    });

    HttpServer::new(move || {
        App::new()
            .app_data(app_state.clone())
            .route("/tasks", web::get().to(get_tasks))
            .route("/tasks", web::post().to(create_task))
    })
    .bind("127.0.0.1:8080")?
    .run()
    .await
}

Now your API is connected to Supabase! The tasks will persist in your PostgreSQL database. Test it with:

# Create a task (it will be saved to Supabase)
curl -X POST http://localhost:8080/tasks     -H "Content-Type: application/json"     -d '{"title": "Test Supabase Integration", "completed": false}'

# Get all tasks (will fetch from Supabase)
curl http://localhost:8080/tasks

Security Considerations

  • Never commit your .env file to version control
  • Use appropriate Row Level Security policies in Supabase
  • Consider implementing user authentication
  • Use environment-specific API keys

This basic setup provides a solid foundation for building robust web services with Rust. The combination of Rust's safety guarantees and Actix-web's performance makes it an excellent choice for building high-performance APIs.