This two-part article covers how we can build an Issue tracking application using React for the frontend, and Shuttle and Actix Web for the backend. It also uses Clerk for authentication in the frontend and protecting the Rest APIs in the backend.
This article covers the Rust backend, and the second part covers the React frontend.
Here is the source code of the complete project if you want to follow along, and a link to the demo.
To start this project we will use the Clerk template from Shuttle’s example repository that has a barebone setup of Shuttle, Actix Web, Vite (React), and Clerk.
The backend uses the clerk-rs community crate to create a connection between the backend application and Clerk project. This crate has many different APIs for different use cases. In the template there is an endpoint to fetch all user records using the clerk-rs
user API. We can easily verify a user's identity when trying to access protected routes using the ClerkMiddleware
from the crate.
We will use Postgres for the database which will run in a docker container so make sure you also have docker installed in your local machine.
Let's get started
First, we need to clone the template from the Shuttle’s example repository using Shuttle CLI:
cargo shuttle init --from shuttle-hq/shuttle-examples --subfolder actix-web/clerk
You will get a few prompts to create a new project using this template. Make sure you provide a unique name because when you deploy this project on Shuttle this project name will become the sub-domain. And once a name is taken it cannot be used for any other projects
After that, cd
into the project directory.
Setup a new application in Clerk
Head on to Clerk’s official website, sign in or sign up if you don’t have an account, and create a new project in the dashboard. Give a name to your project and select the providers using which users can sign-in or sign-up in your application.
After that, you will get a Publishable Key and a Secret key which we will use later in our application.
Build the backend
Now we can start updating the existing backend code to write and build the Rest APIs for our application.
cd
into the backend, and rename the Secrets.example.toml
file to Secrets.toml
:
cd backend
mv Secrets.example.toml Secrets.toml
Copy the Secret key from the Clerk's dashboard and add it to the Secrets.toml
file:
CLERK_SECRET_KEY = "sk_test_vsWrxxxxxxxxxxxxxxxxxxxxxxxxxtfTr"
⚠️ Do not commit this file, so make sure you have added this file in the .gitignore
.
Now, we need to write a new migration in a schema.sql
file which will create a new issues
table in our Postgres database.
Create schema.sql
and add the schema:
CREATE TABLE IF NOT EXISTS issues (
id SERIAL PRIMARY KEY,
title VARCHAR(100) NOT NULL,
description VARCHAR(300) NOT NULL,
status VARCHAR(10) NOT NULL,
label VARCHAR(10) NOT NULL,
author VARCHAR NOT NULL
);
We can migrate this schema using SQLX. So add the SQLX crate to your dependency:
cargo add sqlx
To connect to a shared Postgres database managed by Shuttle you need to add the crate with features specific to Postgres and SQLX to your dependencies:
cargo add shuttle-shared-db -F shuttle-shared-db/postgres -F shuttle-shared-db/sqlx
Connect to the shared Postgres DB
Let’s edit the main.rs
file.
First, we need to bring SQLX into the scope:
use sqlx::{Executor, FromRow, PgPool};
Add the pool field to the AppState
struct to share the Postgres pool instance in our whole application:
struct AppState {
client: Clerk,
pool: PgPool,
}
Next, pass the #[shuttle_shared_db::Postgres] pool: PgPool
argument to your shuttle_runtime::main
function and connect to Postgres using SQLX connect pool PgPool
.
#[shuttle_runtime::main]
async fn actix_web(
#[shuttle_secrets::Secrets] secrets: SecretStore,
#[shuttle_shared_db::Postgres] pool: PgPool,
) -> ShuttleActixWeb<impl FnOnce(&mut ServiceConfig) + Send + Clone + 'static> {
// DB Pool
pool.execute(include_str!("../schema.sql"))
.await
.map_err(CustomError::new)?;
// Clerk integration
let clerk_secret_key = secrets
.get("CLERK_SECRET_KEY")
.expect("Clerk Secret key is not set");
let clerk_config = ClerkConfiguration::new(None, None, Some(clerk_secret_key), None);
let client = Clerk::new(clerk_config.clone());
// Create new app state
let state = web::Data::new(AppState { client, pool });
let app_config = move |cfg: &mut ServiceConfig| {
cfg.service(
web::scope("/api")
.wrap(ClerkMiddleware::new(clerk_config, None, true))
.service(get_user)
)
.service(actix_files::Files::new("/", "./frontend/dist").index_file("index.html"))
.app_data(state);
};
Ok(app_config.into())
}
The above function call will create the Postgres pool, and then SQLX will migrate the schema we have created before. The Clerk configuration is already defined in the template for us which uses the CLERK_SECRET_KEY
we got from the Clerk project we have created earlier.
The Clerk middleware protects all the paths under /api
, so that only signed in users can access them.
Try running the application using Shuttle CLI:
cargo shuttle run
🗨️ Make sure docker is running in your local machine because after you run the above command Shuttle will pull the Postgres docker image and start a docker container.
The backend application can now query the Postgres database using SQLX. Let's create some CRUD endpoints for our application.
Issue structs
To define the fields of an issue we will create an Issue
struct that is used to define the response and request payload of our Read, Update, and Delete requests, and a NewIssue
struct for the Create requests:
#[derive(Serialize, Deserialize, FromRow)]
struct Issue {
id: i32,
title: String,
description: String,
status: String,
label: String,
author: String,
}
#[derive(Serialize, Deserialize, FromRow)]
struct NewIssue {
title: String,
description: String,
status: String,
label: String,
author: String,
}
Extract the Clerk JWT claim from a request
In some of the endpoints we need to check for the user's authorization. For that we will look for a user id which can be found in the JWT claim sub
property:
async fn get_jwt_claim(service_request: &ServiceRequest, clerk_client: &Clerk) -> Option<ClerkJwt> {
let claim = clerk_authorize(service_request, clerk_client, true).await;
match claim {
Ok(value) => Some(value.1),
Err(_) => None,
}
}
CRUD endpoints
Add the create issue endpoint which can query our database to insert a new issue into the table like this:
#[post("/issue")]
async fn add_issue(payload: web::Json<NewIssue>, state: web::Data<AppState>) -> impl Responder {
let create_query: Result<Issue, sqlx::Error> = sqlx::query_as(
"INSERT INTO issues (title, description, status, label, author) VALUES ($1, $2, $3, $4, $5) RETURNING *"
)
.bind(&payload.title)
.bind(&payload.description)
.bind(&payload.status)
.bind(&payload.label)
.bind(&payload.author)
.fetch_one(&state.pool)
.await;
if create_query.is_err() {
return HttpResponse::InternalServerError().json(serde_json::json!({
"status":"FAILED",
"message":"Failed to create an issue"
}));
}
HttpResponse::Ok().json(serde_json::json!({
"status":"SUCCESS",
"message":"Created the issue successfully"
}))
}
This endpoint will retrieve all the issues from the database:
#[get("/issues")]
async fn get_issues(state: web::Data<AppState>) -> impl Responder {
let query: Result<Vec<Issue>, sqlx::Error> = sqlx::query_as("SELECT * FROM issues")
.fetch_all(&state.pool)
.await;
let issues = match query {
Ok(value) => value,
Err(e) => {
return HttpResponse::InternalServerError().json(serde_json::json!({
"status": "FAILED",
"message": e.to_string(),
}));
}
};
HttpResponse::Ok().json(issues)
}
This endpoint function will extract the issue_id
slug from the path using the Path extractor from Actix Web and query our database where the issue id
is equal to the issue_id
slug of the path.
#[get("/issue/{issue_id}")]
async fn get_issue(state: web::Data<AppState>, path: web::Path<i32>) -> impl Responder {
let issue_id = path.into_inner();
let query: Result<Issue, sqlx::Error> = sqlx::query_as("SELECT * FROM issues WHERE id=$1")
.bind(issue_id)
.fetch_one(&state.pool)
.await;
let issue = match query {
Ok(value) => value,
Err(_) => {
return HttpResponse::InternalServerError().json(serde_json::json!({
"status":"FAILED",
"message":"Something went wrong."
}));
}
};
HttpResponse::Ok().json(serde_json::json!({
"status": "SUCCESS",
"data": issue,
}))
}
To update an issue by id we will use slug from the path which will contain the issue_id
to query our database and update the record if it exists in the database. Before updating the record we can query if the record exists in the database or not:
#[patch("/issue/{issue_id}")]
async fn update_issue(
payload: web::Json<NewIssue>,
state: web::Data<AppState>,
path: web::Path<i32>,
req: HttpRequest,
) -> impl Responder {
let issue_id = path.into_inner();
let service_req = ServiceRequest::from_request(req);
let claim = get_jwt_claim(&service_req, &state.client).await;
if claim.is_none() {
return HttpResponse::Forbidden().json(serde_json::json!({
"status":"FAILED",
"message":"Not authorized to update the issue."
}));
}
let query: Result<Issue, sqlx::Error> = sqlx::query_as("SELECT * FROM issues WHERE id=$1")
.bind(issue_id)
.fetch_one(&state.pool)
.await;
match query {
Ok(issue) => {
if issue.author != claim.unwrap().sub {
return HttpResponse::Unauthorized().json(serde_json::json!({
"status":"FAILED",
"message":"Not authorized to update the issue."
}));
}
}
Err(_) => {
return HttpResponse::NotFound().json(serde_json::json!({
"status":"FAILED",
"message":"Issue does not exist."
}));
}
}
let update_query: Result<Issue, sqlx::Error> = sqlx::query_as(
"UPDATE issues SET title=$1, description=$2, status=$3, label=$4 WHERE id=$5",
)
.bind(&payload.title)
.bind(&payload.description)
.bind(&payload.status)
.bind(&payload.label)
.bind(issue_id)
.fetch_one(&state.pool)
.await;
if update_query.is_err() {
return HttpResponse::InternalServerError().json(serde_json::json!({
"status":"FAILED",
"message":"Failed to update the issue"
}));
}
HttpResponse::Ok().json(serde_json::json!({
"status": "SUCCESS",
"message":"Updated successfully"
}))
}
This endpoint is for deleting the issue by id
from the database:
#[delete("/issue/{issue_id}")]
async fn delete_issue(
state: web::Data<AppState>,
path: web::Path<i32>,
req: HttpRequest,
) -> impl Responder {
let service_req = ServiceRequest::from_request(req);
let claim = get_jwt_claim(&service_req, &state.client).await;
if claim.is_none() {
return HttpResponse::Forbidden().json(serde_json::json!({
"status":"FAILED",
"message":"Not authorized to delete the issue."
}));
}
let issue_id = path.into_inner();
let query: Result<Issue, sqlx::Error> = sqlx::query_as("SELECT * FROM issues WHERE id=$1")
.bind(issue_id)
.fetch_one(&state.pool)
.await;
match query {
Ok(issue) => {
if issue.author != claim.unwrap().sub {
return HttpResponse::Unauthorized().json(serde_json::json!({
"status":"FAILED",
"message":"No authorized to delete the issue."
}));
}
}
Err(_) => {
return HttpResponse::NotFound().json(serde_json::json!({
"status":"FAILED",
"message":"Issue does not exist."
}));
}
}
let delete_query: Result<Issue, sqlx::Error> = sqlx::query_as("DELETE FROM issues WHERE id=$1")
.bind(issue_id)
.fetch_one(&state.pool)
.await;
if delete_query.is_err() {
return HttpResponse::InternalServerError().json(serde_json::json!({
"status":"FAILED",
"message":"Failed to delete the issue"
}));
}
HttpResponse::Ok().json(serde_json::json!({
"status": "SUCCESS",
"message":"Deleted successfully"
}))
}
User endpoints
The template provides a get_user
handler function and a UserModel
struct.
We will edit the handler function to use the get_jwt_claim
function:
#[get("/user/me")]
async fn get_user(state: web::Data<AppState>, req: HttpRequest) -> impl Responder {
let service_req = ServiceRequest::from_request(req);
let claim = get_jwt_claim(&service_req, &state.client).await;
if claim.is_none() {
return HttpResponse::Forbidden().json(serde_json::json!({
"status":"FAILED",
"message":"Not authorized to update the issue."
}));
}
let Ok(user) = User::get_user(&state.client, &claim.unwrap().sub).await else {
return HttpResponse::InternalServerError().json(serde_json::json!({
"message": "Unable to retrieve user",
}));
};
HttpResponse::Ok().json(Into::<UserModel>::into(user))
}
We need one last endpoint which can fetch the user details by their user_id
. This endpoint will be used to fetch issue author metadata from Clerk:
#[get("/user/{user_id}")]
async fn get_user_by_id(state: web::Data<AppState>, path: web::Path<String>) -> impl Responder {
let user_id = path.into_inner();
let Ok(user) = User::get_user(&state.client, &user_id).await else {
return HttpResponse::InternalServerError().json(serde_json::json!({
"status": "FAILED",
"message": "Unable to retrieve all users",
}));
};
HttpResponse::Ok().json(Into::<UserModel>::into(user))
}
Now, let’s add all these endpoints to our main
function under the /api
scope which is wrapped by Clerk middleware for protecting our endpoint routes and only allowing authenticated users to access these routes. After adding all the endpoints the main
function you should have something like this.
Building the frontend and deploying
One important thing to notice is that this piece of code
.service(actix_files::Files::new("/", "./frontend/dist").index_file("index.html"))
is going the serve the static pages of the frontend application located in the ./frontend/dist
directory whenever we build our Vite application, and this final output is optimized for production.
Follow along to part 2 where we build the frontend and deploy.