Create Rust Axum API and Deploy it on GCP Cloud Run

In this blog post, I’ll guide you through creating a simple Rust API using the Axum framework and deploying it on Google Cloud Run, backed by a PostgreSQL database hosted on Cloud SQL.

Introduction

I want to start by appreciating the guidance from some amazing people working with Rust.

Initially, I’ve created the Rust API using Warp and Actix-web to see which one I’d like more. Then, learned from Joe Bowers, Chris Dickinson, David Kern, and Sietse van der Bom to use Axum instead.

After using the three, felt that Axum was the easiest to get started even though the documentation wasn’t as good as Actix-web. I’ve also liked that Axum was created by Tokio and suggested by many folks who know the pains of Rust in production better than me. So, Axum it is for this article.

Also, Sietse also introduced me to Cornucopia for PostgreSQL, emphasizing its convenience and performance over Sqlx, Seaorm or simply having the SQL queries directly in the main.rs.

Prerequisites

Before we start, make sure you have the following tools installed:

  1. Docker & Docker Compose: For containerizing the Rust application.
  2. Google Cloud SDK: For deploying the container to Google Cloud Run.
  3. Rust and Cargo: For building the Rust application.
  4. Cornucopia: For generating the Rust code based on SQL queries.

Rust Axum API Project

You can set up the Rust project yourself, or you can clone an existing project from GitHub.

Option 1: Clone from GitHub

If you prefer to skip the initial setup, you can clone the project from my GitHub repository – https://github.com/tiago-peres/my-first-axum-api

git clone https://github.com/tiago-peres/my-first-axum-api.git
cd my-first-axum-api

Option 2: New Rust Project

If you prefer to create the project yourself, follow these steps:

1. Create a new Rust project. Open your terminal and run:

cargo new axum_wasm_postgres
cd axum_wasm_postgres

2. Add the necessary dependencies to your Cargo.toml:

[package]
name = "axum_wasm_postgres"
version = "0.1.0"
edition = "2021"

[dependencies]
# Axum web framework
axum = "0.6"

# Environment variables
dotenv = "0.15"

# Tower (used for middleware and routing)
tower = "0.4"
tower-http = { version = "0.3", features = ["trace"] }

# Tracing for logging
tracing-subscriber = "0.3"
tracing = "0.1"

# Postgres types with derive feature
postgres-types = { version = "*", features = ["derive"] }

# Asynchronous runtime and utilities
tokio = { version = "*", features = ["full"] }
futures = "*"

# Cornucopia for async Postgres interactions
cornucopia_async = { version = "*", features = ["with-serde_json-1"] }

# Async Postgres driver
tokio-postgres = { version = "*", features = [
    "with-serde_json-1",
    "with-time-0_3",
    "with-uuid-1",
    "with-eui48-1"
]}

# Serialization and deserialization
serde = { version = "*", features = ["derive"] }
serde_json = "*"

# Extra types
time = "*"
uuid = "*"
eui48 = "*"
rust_decimal = { version = "*", features = ["db-postgres"] }

3. Create a file named queries/users.sql for Cornucopia to define your SQL queries:

-- queries/users.sql

--! get_all_users
SELECT id, name FROM users;

--! insert_user
INSERT INTO users (name) VALUES (:name) RETURNING id, name;

--! get_user
SELECT id, name FROM users WHERE id = :id;

--! delete_user
DELETE FROM users WHERE id = :id RETURNING id, name;

--! update_user
UPDATE users SET name = :name WHERE id = :id RETURNING id, name;

4. Create a Dockerfile:

FROM rust:latest as builder
WORKDIR /usr/src/axum_wasm_postgres
COPY . .
RUN apt-get update && apt-get install -y libpq-dev
RUN cargo install --path .

FROM ubuntu:22.04
RUN apt-get update && apt-get install -y ca-certificates libpq-dev && rm -rf /var/lib/apt/lists/*
COPY --from=builder /usr/local/cargo/bin/axum_wasm_postgres /usr/local/bin/axum_wasm_postgres
CMD ["axum_wasm_postgres"]

5. Create the init.sql. This will be used to start the DB with a users table:

CREATE TABLE IF NOT EXISTS users (
    id SERIAL PRIMARY KEY,
    name VARCHAR(100)
);

6. Create the docker-compose.yml making sure the DB uses the init.sql file:

version: '3.8'

services:
  db:
    image: postgres:13
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: password
      POSTGRES_DB: mydb
    ports:
      - "5432:5432"
    volumes:
      - postgres_data:/var/lib/postgresql/data
      - ./init.sql:/docker-entrypoint-initdb.d/init.sql

  api:
    build: .
    depends_on:
      - db
    environment:
      DATABASE_URL: postgres://postgres:password@db:5432/mydb
    ports:
      - "8080:8080"
    command: ["axum_wasm_postgres"]

volumes:
  postgres_data:

7. Generate the Cornucopia file. To do so, you need to have a DB up and running first. So, use Docker Compose to start the PostgreSQL database service:

docker-compose up -d db

open Git Bash and run the Cornucopia CLI against the database to generate the new query files:

cornucopia live postgres://postgres:password@localhost:5432/mydb

now you should see the file and can shutdown the DB service

docker-compose down

8. Replace the content of src/main.rs with:

use axum::{
    routing::{get, post, put, delete},
    Router,
    Json,
    Extension,
};
use serde::{Deserialize, Serialize};
use tokio_postgres::NoTls;
use std::net::SocketAddr;
use std::sync::Arc;
use dotenv::dotenv;
use tower_http::trace::TraceLayer;
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;

mod cornucopia;
use cornucopia::queries::users::{
    get_all_users, insert_user, get_user, delete_user as db_delete_user, update_user,
};

#[derive(Serialize, Deserialize)]
struct User {
    id: i32,
    name: String,
}

#[derive(Deserialize)]
struct AddUser {
    name: String,
}

#[derive(Deserialize)]
struct EditUser {
    name: String,
}

#[tokio::main]
async fn main() {
    tracing_subscriber::registry()
        .with(tracing_subscriber::fmt::layer())
        .init();

    dotenv().ok();

    let (client, connection) = tokio_postgres::connect(
        &std::env::var("DATABASE_URL").expect("DATABASE_URL must be set"),
        NoTls,
    )
    .await
    .expect("Failed to connect to database");

    tokio::spawn(async move {
        if let Err(e) = connection.await {
            eprintln!("connection error: {}", e);
        }
    });

    let client = Arc::new(client);

    let app = Router::new()
        .route("/users", get(get_users).post(add_user))
        .route("/users/:id", get(get_single_user).put(edit_user).delete(delete_user))
        .layer(Extension(client))
        .layer(TraceLayer::new_for_http());

    let addr = SocketAddr::from(([0, 0, 0, 0], 8080));
    println!("Listening on {}", addr);

    axum::Server::bind(&addr)
        .serve(app.into_make_service())
        .await
        .unwrap();
}

async fn get_users(Extension(client): Extension<Arc<tokio_postgres::Client>>) -> Json<Vec<User>> {
    let mut stmt = get_all_users();
    let rows = stmt.bind(&*client).all().await.expect("Failed to execute query");
    let users = rows.iter().map(|row| User {
        id: row.id,
        name: row.name.clone(),
    }).collect();
    Json(users)
}

async fn add_user(
    Extension(client): Extension<Arc<tokio_postgres::Client>>,
    Json(payload): Json<AddUser>,
) -> Json<User> {
    let mut stmt = insert_user();
    let row = stmt.bind(&*client, &payload.name).one().await.expect("Failed to execute query");
    let user = User {
        id: row.id,
        name: row.name.clone(),
    };
    Json(user)
}

async fn get_single_user(
    Extension(client): Extension<Arc<tokio_postgres::Client>>,
    axum::extract::Path(id): axum::extract::Path<i32>,
) -> Json<User> {
    let mut stmt = get_user();
    let row = stmt.bind(&*client, &id).one().await.expect("Failed to execute query");
    let user = User {
        id: row.id,
        name: row.name.clone(),
    };
    Json(user)
}

async fn delete_user(
    Extension(client): Extension<Arc<tokio_postgres::Client>>,
    axum::extract::Path(id): axum::extract::Path<i32>,
) -> Json<User> {
    let mut stmt = db_delete_user();
    let row = stmt.bind(&*client, &id).one().await.expect("Failed to execute query");
    let user = User {
        id: row.id,
        name: row.name.clone(),
    };
    Json(user)
}

async fn edit_user(
    Extension(client): Extension<Arc<tokio_postgres::Client>>,
    axum::extract::Path(id): axum::extract::Path<i32>,
    Json(payload): Json<EditUser>,
) -> Json<User> {
    let mut stmt = update_user();
    let row = stmt.bind(&*client, &payload.name, &id).one().await.expect("Failed to execute query");
    let user = User {
        id: row.id,
        name: row.name.clone(),
    };
    Json(user)
}

9. Now, to start both the PostgreSQL database and the Rust API server, simply run

docker-compose up --build

Once the services are up and running, you can test the API endpoints using curl or any API client like Postman.

Deploy to Google Cloud Run

1. Build the Docker image for your Rust service:

docker build -t europe-west1-docker.pkg.dev/tiago-peres/my-repo/axum-wasm-postgres .

2. Push the Docker image to Google Artifact Registry:

docker push europe-west1-docker.pkg.dev/tiago-peres/my-repo/axum-wasm-postgres

3. Create a Cloud SQL instance and create the users table. Here I’ve used Cloud Shell (Note that the following image doesn’t show the part of the table creation but simply use the code present in init.sql to create it once connected to the database).

4. Deploy the service to Google Cloud Run, referencing the Cloud SQL instance and the Docker image in Artifact Registry:

gcloud run deploy axum-wasm-postgres --image europe-west1-docker.pkg.dev/tiago-peres/my-repo/axum-wasm-postgres --platform managed --region europe-west1 --add-cloudsql-instances tiago-peres:europe-west1:my-sql-instance --set-env-vars DATABASE_URL=postgres://postgres:password@/postgres?host=/cloudsql/tiago-peres:europe-west1:my-sql-instance --allow-unauthenticated

5. Now that the service is deployed, you can test the API endpoints using Postman or any other API client.

Related Stories