Build a Blog API with Actix Web
Build a type-safe REST API for blog posts using Rust, actix-web, and PostgreSQL.
Introduction
30 Second Summary
Every time you read a blog post, publish an article, or edit content online, there's an API handling those operations behind the scenes. The safety guarantees that make these APIs reliable in production are exactly what Rust's type system gives you for free at compile time.
In this project, you will build a JSON REST API for a blog platform using Rust's actix-web framework. Your API will connect to a PostgreSQL database running in Docker, giving you full create, read, update, and delete functionality for blog posts.
What You'll Build
A Rust-powered REST API with five endpoints for managing blog posts, backed by PostgreSQL and built with actix-web's type-safe extractor system.
By the end of this project, you'll have:
- A running actix-web server with five REST endpoints for creating, reading, updating, and deleting blog posts.
- A Docker Compose setup that spins up PostgreSQL and initializes your database schema automatically.
- Type-safe request handling using actix-web's extractor system, where the compiler catches malformed requests before your code ever runs.
- Secret Mission: Add query-parameter filtering and pagination to the list endpoint.
Are there any prerequisites?
This project assumes familiarity with Rust fundamentals (ownership, structs, traits, async/await) and basic REST API concepts. You should also have Rust installed via rustup and Docker running on your machine.
Set Up the Project and Database
Every Rust web project starts with two foundations: a Cargo project that organizes your code, and a database that stores your data.
In this step, you'll create the blog-api project, configure all its dependencies, and spin up a PostgreSQL database using Docker Compose.
In this step, get ready to:
- Update your Rust toolchain and create a new Cargo project.
- Add actix-web, sqlx, and supporting dependencies.
- Launch PostgreSQL in Docker with an automatic schema.
Update your Rust toolchain and create the project
Rust's package manager and build tool is called Cargo. Before creating the project, you need to make sure your Rust toolchain is up to date so you have the latest compiler and standard library.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal and press Enter to open it.
- Update your Rust toolchain by running this command:
rustup update
You should see output showing Rust downloading and installing the latest stable version.
Seeing a network error?
Make sure you're connected to the internet. If you're behind a corporate proxy, check that your terminal's proxy environment variables are set correctly.
Help me troubleshoot my rustup update failure.
- Confirm your Rust version by running:
rustc --version
You should see output in the format rustc 1.88.0 followed by a commit hash and date. The version number should be 1.88 or higher.
✔️ I see version 1.88 or higher
Your Rust toolchain is ready. actix-web 4 requires Rust 1.88 as its minimum supported version, so you're all set.
ⓧ I see an older version
If rustup update completed successfully but you still see an older version, your default toolchain might be pinned.
- Set your default to the latest stable by running:
rustup default stable
- Run rustc --version again to confirm it shows 1.88 or higher.
ⓧ Command not found
Rust is not installed on your machine. Install it using the official installer.
- Install Rust by running:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
- Follow the prompts and select the default installation.
- Restart your terminal, then run rustc --version to confirm it shows 1.88 or higher.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project and move into it by running these commands:
cargo new blog-api
cd blog-api
- Verify the project works by running:
cargo run
You should see Hello, world! printed in your terminal. This confirms Cargo compiled and ran the default template successfully.
Not seeing Hello, world?
Make sure you cd blog-api into the project folder before running cargo run. Cargo needs to find the Cargo.toml file in your current directory.
Help me troubleshoot why cargo run isn't working.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Cursor and press Enter to open it.
- Click File in the top menu bar.
- Click Open Folder.
- Navigate to your Desktop and select the blog-api folder, then click Open.
Use the integrated terminal
From now on, use the terminal inside Cursor. Open it with Ctrl+` (backtick key, same shortcut on both macOS and Windows). It automatically opens inside your project folder.
Add project dependencies to Cargo.toml
Your project needs several crates (Rust libraries) to handle HTTP routing, database queries, and JSON serialization. All dependencies go in Cargo.toml, which Cargo reads to download and compile them.
- In the Cursor sidebar, click on Cargo.toml to open it.
- Replace the entire contents of the file with the following:
[package]
name = "blog-api"
version = "0.1.0"
edition = "2021"
[dependencies]
# Web framework for routing and HTTP handling
actix-web = "4"
# Async database driver for PostgreSQL
sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "uuid", "chrono"] }
# Serialization/deserialization for JSON conversion
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# Load environment variables from .env file
dotenvy = "0.15"
# Logging infrastructure
env_logger = "0.11"
log = "0.4"
# Date/time types with serde support
chrono = { version = "0.4", features = ["serde"] }
# UUID generation and parsing with serde support
uuid = { version = "1", features = ["serde"] }
What are these dependencies?
actix-web is the HTTP framework that handles routing and request/response lifecycle. sqlx is an async database driver that maps query results directly to Rust structs.
serde converts Rust structs to and from JSON automatically via derive macros. The runtime-tokio feature on sqlx aligns it with actix-web's internal async runtime.
- Save Cargo.toml.
- In your integrated terminal, download all dependencies by running:
cargo build
You should see Cargo downloading and compiling crates. The first build takes a minute or two as it compiles all dependencies.
Seeing compilation errors?
Make sure you replaced the entire Cargo.toml file, including the [package] section. A missing edition = "2021" or a typo in a feature name will cause errors.
Help me fix my Cargo.toml compilation errors.
✔️ Awesome, I've got everything!
Great. Make sure Cargo.toml is saved before moving on.
ⓧ I'd like to double check the full code
[package]
name = "blog-api"
version = "0.1.0"
edition = "2021"
[dependencies]
actix-web = "4"
sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "uuid", "chrono"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
dotenvy = "0.15"
env_logger = "0.11"
log = "0.4"
chrono = { version = "0.4", features = ["serde"] }
uuid = { version = "1", features = ["serde"] }
Create the Docker Compose and database schema
Your API needs a PostgreSQL database to store blog posts. Instead of installing PostgreSQL directly on your machine, you'll use Docker to run it in a container. This keeps your system clean and makes the setup reproducible.
Why Docker instead of a local install?
A Docker container runs PostgreSQL in isolation. You can start, stop, and delete it without affecting anything else on your machine. The compose.yaml file declares exactly what you need, so anyone can recreate the same environment with one command.
- In the Cursor sidebar, right-click and select New File.
- Name the file compose.yaml and press Enter.
- Paste the following into compose.yaml:
services:
db:
image: postgres:17-alpine
restart: unless-stopped
shm_size: 128mb
environment:
POSTGRES_USER: bloguser
POSTGRES_PASSWORD: blogpass
POSTGRES_DB: blogdb
ports:
- "5432:5432"
volumes:
# Run init.sql on first startup to create the schema
- ./init.sql:/docker-entrypoint-initdb.d/init.sql
# Persist data between container restarts
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:
What does this compose file do?
- postgres:17-alpine pulls a lightweight PostgreSQL 17 image.
- The environment block creates a user, password, and database automatically on first run.
- The volumes mount init.sql into the container's startup directory. PostgreSQL runs any .sql files in that directory the first time it initializes.
- The pgdata named volume persists your data between container restarts.
- Save compose.yaml.
✔️ Awesome, I've got everything!
Great. Make sure compose.yaml is saved before moving on.
ⓧ I'd like to double check the full code
services:
db:
image: postgres:17-alpine
restart: unless-stopped
shm_size: 128mb
environment:
POSTGRES_USER: bloguser
POSTGRES_PASSWORD: blogpass
POSTGRES_DB: blogdb
ports:
- "5432:5432"
volumes:
- ./init.sql:/docker-entrypoint-initdb.d/init.sql
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:
Next, you'll create the SQL script that defines your database table.
- Create another new file in the project root called init.sql.
- Paste the following into init.sql:
-- Enable UUID generation functions
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- Define the posts table with auto-generated UUIDs and timestamps
CREATE TABLE posts (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
title VARCHAR(255) NOT NULL,
body TEXT NOT NULL,
published BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
What does this schema do?
- uuid-ossp is a PostgreSQL extension that provides the uuid_generate_v4() function for generating random UUIDs.
- Each post gets a unique id generated automatically. No sequential integer IDs that leak information about your data volume.
- created_at and updated_at use TIMESTAMPTZ (timestamp with timezone) for proper time handling across regions.
- Save init.sql.
✔️ Awesome, I've got everything!
Great. Make sure init.sql is saved before moving on.
ⓧ I'd like to double check the full code
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE TABLE posts (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
title VARCHAR(255) NOT NULL,
body TEXT NOT NULL,
published BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
Finally, you need a .env file that tells your Rust application how to connect to the database.
- Create a new file called .env in the project root.
- Paste the following into .env:
DATABASE_URL=postgres://bloguser:blogpass@localhost:5432/blogdb
RUST_LOG=info
- Save .env.
What are these variables?
DATABASE_URL is the connection string that sqlx uses to reach PostgreSQL. The credentials match what you defined in compose.yaml. RUST_LOG=info tells the logger to show informational messages when the server runs.
Now you can start the database container.
- In your integrated terminal, start PostgreSQL by running:
docker compose up -d
The -d flag runs the container in the background so you keep your terminal free.
- Confirm the container is running by checking:
docker compose ps
You should see your db service listed with a STATUS showing Up.
Container not starting?
If you see an error about port 5432 already in use, another PostgreSQL instance is running on your machine. Stop it first, or change the host port in compose.yaml to something like "5433:5432" (and update .env accordingly).
If Docker itself isn't running, make sure Docker Desktop is open and fully started before retrying.
Help me fix my Docker Compose issue.
Your project skeleton is ready and your database is running. Next up, you'll connect your Rust application to PostgreSQL and configure shared state for your handlers.
Connect to the Database and Configure Shared State
Your database is running in Docker, but your Rust application has no way to talk to it yet. Each HTTP request your API handles will need to run a SQL query, so you need an efficient way to manage database connections.
In this step, you will create a connection pool that keeps multiple database connections ready and wire it into actix-web's shared state system. Every request handler will be able to pull a connection from the pool without opening a new one from scratch.
In this step, get ready to:
- Create a PostgreSQL connection pool with sqlx.
- Configure shared application state so every handler can access the pool.
- Start the HTTP server with logging middleware and verify the database connection.
Create the connection pool
Opening a fresh database connection for every incoming request is slow. A connection pool pre-opens a set of connections and lends them out on demand.
What is a connection pool?
Think of it like a parking garage for database connections. Instead of building a new garage space every time a car arrives, you keep five spaces ready. When a request needs the database, it grabs a free connection. When it finishes, the connection goes back into the pool for the next request.
sqlx's PgPoolOptions handles all of this for you. You configure it once at startup and share it across all request handlers using actix-web's web::Data wrapper.
Your project's src/main.rs will hold an AppState struct containing the pool. actix-web wraps this in an Arc internally, so every worker thread gets cheap, thread-safe access.
Before writing the main server code, you need to create placeholder files for the modules that main.rs declares. Without them, Rust will refuse to compile.
- Create the placeholder module files by running these commands from your blog-api folder:
touch src/models.rs
touch src/handlers.rs
- Open src/handlers.rs in your editor and paste the following stub code:
use actix_web::{delete, get, post, put, HttpResponse};
// Placeholder handlers - we'll implement these in the next steps
#[post("/posts")]
pub async fn create_post() -> HttpResponse {
todo!()
}
#[get("/posts")]
pub async fn get_posts() -> HttpResponse {
todo!()
}
#[get("/posts/{id}")]
pub async fn get_post() -> HttpResponse {
todo!()
}
#[put("/posts/{id}")]
pub async fn update_post() -> HttpResponse {
todo!()
}
#[delete("/posts/{id}")]
pub async fn delete_post() -> HttpResponse {
todo!()
}
What does todo!() do?
The todo!() macro is Rust's built-in placeholder for unfinished code. It compiles successfully but panics at runtime if called. This lets you set up your module structure now and fill in the real logic later without compiler errors.
- Save src/handlers.rs.
- Leave src/models.rs empty for now. You will add structs to it in the next step.
- Confirm the files exist by running:
ls src/
You should see handlers.rs, main.rs, and models.rs in the output.
Wire up the main function
The #[actix_web::main] macro sets up a Tokio async runtime under the hood. Everything inside your main() function can use .await directly.
- Replace the entire contents of src/main.rs with the following code:
use actix_web::{delete, get, post, put, web, App, HttpResponse, HttpServer, middleware};
use sqlx::postgres::PgPoolOptions;
use sqlx::PgPool;
mod models;
mod handlers;
pub struct AppState {
pub db: PgPool,
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
// Load environment variables from .env file
dotenvy::dotenv().ok();
// Initialize the logger so we can see request info
env_logger::init();
// Read the database connection string from the environment
let database_url = std::env::var("DATABASE_URL")
.expect("DATABASE_URL must be set");
// Create a connection pool with up to 5 concurrent connections
let pool = PgPoolOptions::new()
.max_connections(5)
.connect(&database_url)
.await
.expect("Failed to create pool");
log::info!("Connected to database");
// Start the HTTP server and configure shared state + routes
HttpServer::new(move || {
App::new()
.wrap(middleware::Logger::default())
.app_data(web::Data::new(AppState { db: pool.clone() }))
.service(handlers::create_post)
.service(handlers::get_posts)
.service(handlers::get_post)
.service(handlers::update_post)
.service(handlers::delete_post)
})
.bind(("127.0.0.1", 8080))?
.run()
.await
}
What does this code do?
- dotenvy::dotenv().ok() loads your .env file so DATABASE_URL is available through std::env::var().
- PgPoolOptions::new().max_connections(5) creates a pool that keeps up to 5 connections open to PostgreSQL at once.
- web::Data::new(AppState { db: pool.clone() }) wraps your pool in actix-web's shared state container. Each worker thread gets access through an internal Arc.
- middleware::Logger::default() logs every incoming request (method, path, status code, response time) to the terminal.
- Each .service() call registers a handler function with the router. The routing macros (#[get], #[post], etc.) on each function define which HTTP method and path it responds to.
Why use AppState instead of passing the pool directly?
Wrapping the pool in a struct gives you room to grow. If you later need to share a cache, a config object, or a background task sender, you add a field to AppState instead of changing every handler signature. One extraction pattern serves all your shared resources.
- Save src/main.rs.
- Build and run the server by running this command:
cargo run
You should see output including Connected to database in the terminal. The server is now running and connected to your PostgreSQL container.
Seeing 'Failed to create pool'?
- Make sure your Docker container is still running. Run docker compose ps and check that the db service shows as running.
- Double check that your .env file contains exactly DATABASE_URL=postgres://bloguser:blogpass@localhost:5432/blogdb with no extra spaces or typos.
- If you see a port conflict error, another process may already be using port 5432. Stop it or restart Docker with docker compose down && docker compose up -d.
Help me debug my database connection error in actix-web with sqlx
✔️ Awesome, I've got everything!
Great. Double check you have saved both src/main.rs and src/handlers.rs before moving on.
ⓧ I'd like to double check the full code
Here is the full src/main.rs:
use actix_web::{delete, get, post, put, web, App, HttpResponse, HttpServer, middleware};
use sqlx::postgres::PgPoolOptions;
use sqlx::PgPool;
mod models;
mod handlers;
pub struct AppState {
pub db: PgPool,
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
dotenvy::dotenv().ok();
env_logger::init();
let database_url = std::env::var("DATABASE_URL")
.expect("DATABASE_URL must be set");
let pool = PgPoolOptions::new()
.max_connections(5)
.connect(&database_url)
.await
.expect("Failed to create pool");
log::info!("Connected to database");
HttpServer::new(move || {
App::new()
.wrap(middleware::Logger::default())
.app_data(web::Data::new(AppState { db: pool.clone() }))
.service(handlers::create_post)
.service(handlers::get_posts)
.service(handlers::get_post)
.service(handlers::update_post)
.service(handlers::delete_post)
})
.bind(("127.0.0.1", 8080))?
.run()
.await
}
And here is the placeholder src/handlers.rs:
use actix_web::{delete, get, post, put, HttpResponse};
#[post("/posts")]
pub async fn create_post() -> HttpResponse {
todo!()
}
#[get("/posts")]
pub async fn get_posts() -> HttpResponse {
todo!()
}
#[get("/posts/{id}")]
pub async fn get_post() -> HttpResponse {
todo!()
}
#[put("/posts/{id}")]
pub async fn update_post() -> HttpResponse {
todo!()
}
#[delete("/posts/{id}")]
pub async fn delete_post() -> HttpResponse {
todo!()
}
Your server is running and talking to PostgreSQL. Next up, you will define your data models and build the Create and Read endpoints so the API can actually store and retrieve blog posts.
Build the Create and Read Endpoints
Your server is running and connected to PostgreSQL. Now it needs to actually do something useful with that connection.
In this step, you will define your data models and build three endpoints: one to create blog posts and two to read them back. Each endpoint uses actix-web's type-safe extractor system to automatically parse incoming requests into strongly typed Rust structs.
In this step, get ready to:
- Define typed data models with serde and sqlx derives.
- Implement a POST endpoint that inserts a new blog post into PostgreSQL.
- Implement GET endpoints that retrieve posts by list or by ID.
Define models with serde and sqlx derives
Before writing any handler logic, you need Rust structs that describe the shape of your data. These structs serve double duty: serde derives let them serialize to and from JSON, while sqlx's FromRow derive lets them map directly from database query results.
- Open src/models.rs in your editor and add the following code:
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
use uuid::Uuid;
// Represents a blog post row in the database
#[derive(Debug, Serialize, FromRow)]
pub struct Post {
pub id: Uuid,
pub title: String,
pub body: String,
pub published: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
What does this code do?
The Post struct mirrors the columns in your posts table from init.sql. Each field maps to a database column by name.
The #[derive(FromRow)] attribute tells sqlx how to convert a database row into this struct automatically. #[derive(Serialize)] lets actix-web convert it to JSON when sending HTTP responses.
You won't see a visible change yet. The next chunk defines the request model that pairs with this.
- Below the Post struct, add the CreatePostRequest struct:
// Represents the JSON body for creating a new post
#[derive(Debug, Deserialize)]
pub struct CreatePostRequest {
pub title: String,
pub body: String,
pub published: Option<bool>,
}
What does this code do?
CreatePostRequest is the shape of JSON that clients send when creating a post. It only needs Deserialize (not Serialize) because it flows inward from the request, never outward in a response.
The published field is Option<bool> so clients can omit it. The handler will default it to false if missing.
- Save src/models.rs.
You won't see a change yet. These structs come to life in the next substep when you wire them into a handler.
✔️ Awesome, I've got everything!
Great. Make sure you have saved src/models.rs before moving on.
ⓧ I'd like to double check the full code
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
use uuid::Uuid;
#[derive(Debug, Serialize, FromRow)]
pub struct Post {
pub id: Uuid,
pub title: String,
pub body: String,
pub published: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Deserialize)]
pub struct CreatePostRequest {
pub title: String,
pub body: String,
pub published: Option<bool>,
}
Implement the POST /posts handler
With your models defined, you can write the first real endpoint. The create handler accepts a JSON body, validates it against your CreatePostRequest struct, and inserts the new post into PostgreSQL.
- Open src/handlers.rs and add the following imports and handler function:
use actix_web::{delete, get, post, put, web, HttpResponse};
use sqlx::query_as;
use uuid::Uuid;
use crate::models::{CreatePostRequest, Post};
use crate::AppState;
// Handler for creating a new blog post
#[post("/posts")]
pub async fn create_post(
state: web::Data<AppState>,
body: web::Json<CreatePostRequest>,
) -> HttpResponse {
// Default published to false if the client omits it
let published = body.published.unwrap_or(false);
// Insert the post and return the full row including generated fields
let result = query_as::<_, Post>(
"INSERT INTO posts (title, body, published) VALUES ($1, $2, $3) RETURNING *"
)
.bind(&body.title)
.bind(&body.body)
.bind(published)
.fetch_one(&state.db)
.await;
match result {
Ok(post) => HttpResponse::Created().json(post),
Err(e) => {
log::error!("Failed to create post: {}", e);
HttpResponse::InternalServerError().json(serde_json::json!({
"error": "Failed to create post"
}))
}
}
}
What does this code do?
The #[post("/posts")] macro registers this function as the handler for POST requests to /posts. Actix-web calls it automatically when a matching request arrives.
The two function parameters are extractors. web::Data<AppState> pulls the shared database pool from application state. web::Json<CreatePostRequest> automatically parses and validates the request body against your struct.
The RETURNING * clause in the SQL makes PostgreSQL return the full inserted row (including the generated id and timestamps). query_as maps that row directly into your Post struct.
- Save src/handlers.rs.
You won't see a change yet. The read handlers below complete the file so you can test everything together.
Implement the GET /posts and GET /posts/{id} handlers
Your API needs two read endpoints: one that lists all posts and one that fetches a single post by its UUID. The single-post endpoint demonstrates actix-web's web::Path extractor, which parses path parameters into typed Rust values.
- In src/handlers.rs, add the get_posts function below create_post:
// Handler for listing all blog posts
#[get("/posts")]
pub async fn get_posts(state: web::Data<AppState>) -> HttpResponse {
let result = query_as::<_, Post>("SELECT * FROM posts ORDER BY created_at DESC")
.fetch_all(&state.db)
.await;
match result {
Ok(posts) => HttpResponse::Ok().json(posts),
Err(e) => {
log::error!("Failed to fetch posts: {}", e);
HttpResponse::InternalServerError().json(serde_json::json!({
"error": "Failed to fetch posts"
}))
}
}
}
What does this code do?
This handler takes only web::Data<AppState> as an extractor because it needs no input from the client. It fetches all posts ordered by newest first and returns them as a JSON array.
fetch_all returns a Vec<Post>. Since Post derives Serialize, actix-web's .json() converts the entire vector into a JSON response automatically.
- Below get_posts, add the get_post function for fetching a single post by ID:
// Handler for fetching a single post by UUID
#[get("/posts/{id}")]
pub async fn get_post(
state: web::Data<AppState>,
path: web::Path<Uuid>,
) -> HttpResponse {
let id = path.into_inner();
let result = query_as::<_, Post>("SELECT * FROM posts WHERE id = $1")
.bind(id)
.fetch_optional(&state.db)
.await;
match result {
Ok(Some(post)) => HttpResponse::Ok().json(post),
Ok(None) => HttpResponse::NotFound().json(serde_json::json!({
"error": "Post not found"
})),
Err(e) => {
log::error!("Failed to fetch post: {}", e);
HttpResponse::InternalServerError().json(serde_json::json!({
"error": "Failed to fetch post"
}))
}
}
}
What does this code do?
web::Path<Uuid> extracts the {id} segment from the URL and parses it as a UUID. If a client sends a malformed ID, actix-web rejects the request before your handler code even runs.
fetch_optional returns Option<Post> instead of erroring when no row matches. This lets you distinguish "post not found" (404) from "database error" (500) cleanly with a match expression.
- Save src/handlers.rs.
- In your terminal, stop the running server with Ctrl+C, then restart it by running:
cargo run
You should see the Connected to database log message followed by the server binding info.
- Open a second terminal and test the create endpoint by running:
curl -X POST http://127.0.0.1:8080/posts \
-H "Content-Type: application/json" \
-d '{"title": "My First Post", "body": "Hello from Rust!"}'
You should see a JSON response with a generated id, your title and body, published: false, and timestamps.
- Test the list endpoint by running:
curl http://127.0.0.1:8080/posts
You should see a JSON array containing the post you just created.
Server won't compile?
If you see errors about update_post or delete_post not being found, this is expected. Your src/main.rs references handlers you have not written yet. Temporarily comment out the two lines .service(handlers::update_post) and .service(handlers::delete_post) in src/main.rs so the project compiles. You will uncomment them in the next step.
If you see a database connection error, make sure your Docker container is still running with docker compose ps. Restart it with docker compose up -d if needed.
Help me debug this error
Why extractors instead of manual parsing?
In frameworks like Express or Flask, you parse JSON manually and hope the fields exist at runtime. Actix-web's extractors move that validation to the type system. If the incoming JSON does not match CreatePostRequest, the framework returns a 400 error before your handler code runs.
This means your handler logic only ever executes with valid, typed data. No null checks, no runtime type assertions, no unexpected missing fields.
✔️ Awesome, I've got everything!
Great. Make sure both src/models.rs and src/handlers.rs are saved before moving on.
ⓧ I'd like to double check the full code
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
use uuid::Uuid;
#[derive(Debug, Serialize, FromRow)]
pub struct Post {
pub id: Uuid,
pub title: String,
pub body: String,
pub published: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Deserialize)]
pub struct CreatePostRequest {
pub title: String,
pub body: String,
pub published: Option<bool>,
}
use actix_web::{delete, get, post, put, web, HttpResponse};
use sqlx::query_as;
use uuid::Uuid;
use crate::models::{CreatePostRequest, Post};
use crate::AppState;
#[post("/posts")]
pub async fn create_post(
state: web::Data<AppState>,
body: web::Json<CreatePostRequest>,
) -> HttpResponse {
let published = body.published.unwrap_or(false);
let result = query_as::<_, Post>(
"INSERT INTO posts (title, body, published) VALUES ($1, $2, $3) RETURNING *"
)
.bind(&body.title)
.bind(&body.body)
.bind(published)
.fetch_one(&state.db)
.await;
match result {
Ok(post) => HttpResponse::Created().json(post),
Err(e) => {
log::error!("Failed to create post: {}", e);
HttpResponse::InternalServerError().json(serde_json::json!({
"error": "Failed to create post"
}))
}
}
}
#[get("/posts")]
pub async fn get_posts(state: web::Data<AppState>) -> HttpResponse {
let result = query_as::<_, Post>("SELECT * FROM posts ORDER BY created_at DESC")
.fetch_all(&state.db)
.await;
match result {
Ok(posts) => HttpResponse::Ok().json(posts),
Err(e) => {
log::error!("Failed to fetch posts: {}", e);
HttpResponse::InternalServerError().json(serde_json::json!({
"error": "Failed to fetch posts"
}))
}
}
}
#[get("/posts/{id}")]
pub async fn get_post(
state: web::Data<AppState>,
path: web::Path<Uuid>,
) -> HttpResponse {
let id = path.into_inner();
let result = query_as::<_, Post>("SELECT * FROM posts WHERE id = $1")
.bind(id)
.fetch_optional(&state.db)
.await;
match result {
Ok(Some(post)) => HttpResponse::Ok().json(post),
Ok(None) => HttpResponse::NotFound().json(serde_json::json!({
"error": "Post not found"
})),
Err(e) => {
log::error!("Failed to fetch post: {}", e);
HttpResponse::InternalServerError().json(serde_json::json!({
"error": "Failed to fetch post"
}))
}
}
}
Your API can now create and read blog posts. Next up, you will add the update and delete endpoints to complete the full CRUD cycle.
Build the Update and Delete Endpoints
Your API can create and read blog posts. Now it needs to let users change their minds. Updates and deletes round out the CRUD cycle and bring your API to feature parity with what a real blog platform expects.
The update handler introduces a new pattern: combining multiple extractors in one function signature. You will pull the post ID from the URL path and the new field values from the JSON body, all type-checked at compile time. The delete handler shows a different response style, returning an empty 204 instead of JSON.
In this step, get ready to:
- Implement a PUT endpoint that partially updates a post.
- Implement a DELETE endpoint that removes a post.
- Test the full CRUD cycle with curl commands.
Implement PUT /posts/{id}
A real blog API needs partial updates. When a user changes only the title, the body and published status should stay the same. This means every field in the update request is optional, and the handler merges incoming values with whatever already exists in the database.
- In src/models.rs, add the UpdatePostRequest struct at the bottom of the file, below CreatePostRequest:
#[derive(Debug, Deserialize)]
pub struct UpdatePostRequest {
pub title: Option<String>,
pub body: Option<String>,
pub published: Option<bool>,
}
What does this struct do?
Every field is wrapped in Option<T>. This tells serde that any field can be absent from the JSON body. If a field is missing, it deserializes to None instead of causing a parse error.
Compare this with CreatePostRequest where title and body are required String values. Rust's type system enforces which fields are mandatory at compile time.
- Save src/models.rs.
Now the handler needs access to this struct. The import line in your handlers file needs updating.
- In src/handlers.rs, find this import line near the top of the file:
use crate::models::{CreatePostRequest, Post};
- Add UpdatePostRequest to the import list so it looks like this:
use crate::models::{CreatePostRequest, Post, UpdatePostRequest};
Now you can write the update handler. This function demonstrates combining multiple extractors in a single signature.
- Add the following update_post function at the bottom of src/handlers.rs, below the get_post handler:
#[put("/posts/{id}")]
pub async fn update_post(
state: web::Data<AppState>,
path: web::Path<Uuid>,
body: web::Json<UpdatePostRequest>,
) -> HttpResponse {
let id = path.into_inner();
// Fetch the existing post to merge with incoming fields
let existing = query_as::<_, Post>("SELECT * FROM posts WHERE id = $1")
.bind(id)
.fetch_optional(&state.db)
.await;
let existing = match existing {
Ok(Some(post)) => post,
Ok(None) => {
return HttpResponse::NotFound().json(serde_json::json!({
"error": "Post not found"
}));
}
Err(e) => {
log::error!("Failed to fetch post: {}", e);
return HttpResponse::InternalServerError().json(serde_json::json!({
"error": "Failed to fetch post"
}));
}
};
// Use incoming values if provided, otherwise keep the existing ones
let title = body.title.as_deref().unwrap_or(&existing.title);
let post_body = body.body.as_deref().unwrap_or(&existing.body);
let published = body.published.unwrap_or(existing.published);
let result = query_as::<_, Post>(
"UPDATE posts SET title = $1, body = $2, published = $3, updated_at = NOW() WHERE id = $4 RETURNING *"
)
.bind(title)
.bind(post_body)
.bind(published)
.bind(id)
.fetch_one(&state.db)
.await;
match result {
Ok(post) => HttpResponse::Ok().json(post),
Err(e) => {
log::error!("Failed to update post: {}", e);
HttpResponse::InternalServerError().json(serde_json::json!({
"error": "Failed to update post"
}))
}
}
}
What does this handler do?
The function signature takes three extractors: web::Data<AppState> for the database pool, web::Path<Uuid> for the post ID in the URL, and web::Json<UpdatePostRequest> for the JSON body. actix-web extracts and validates all three before your code runs.
The merge logic uses unwrap_or to pick the new value if the client sent one, or fall back to the existing database value. This pattern means a PUT with just {"title": "New Title"} changes only the title and leaves everything else untouched.
- Save src/handlers.rs.
Seeing a type mismatch error?
If the compiler complains about UpdatePostRequest not being found, double-check that you added it to the import line in src/handlers.rs and that you saved src/models.rs after adding the struct.
help me fix a type mismatch error in my update_post handler
Implement DELETE /posts/{id}
The delete endpoint is the simplest handler. It does not need to return the deleted post. Instead, it returns an empty 204 No Content response on success, which is the HTTP convention for successful deletions.
- Add the following delete_post function at the bottom of src/handlers.rs, below the update_post handler you just wrote:
#[delete("/posts/{id}")]
pub async fn delete_post(
state: web::Data<AppState>,
path: web::Path<Uuid>,
) -> HttpResponse {
let id = path.into_inner();
// Execute the DELETE query
let result = sqlx::query("DELETE FROM posts WHERE id = $1")
.bind(id)
.execute(&state.db)
.await;
match result {
Ok(res) => {
// Check if a row was actually deleted
if res.rows_affected() == 0 {
HttpResponse::NotFound().json(serde_json::json!({
"error": "Post not found"
}))
} else {
HttpResponse::NoContent().finish()
}
}
Err(e) => {
log::error!("Failed to delete post: {}", e);
HttpResponse::InternalServerError().json(serde_json::json!({
"error": "Failed to delete post"
}))
}
}
}
What does this handler do?
This handler uses sqlx::query instead of query_as because you do not need to map the result into a Post struct. You only care whether the row was deleted.
The rows_affected() method returns how many rows the DELETE statement removed. If it is 0, the ID did not match any post, so you return 404. Otherwise you return 204 with no body using .finish().
- Save src/handlers.rs.
Compiler error about missing method?
Make sure you are using sqlx::query (the full path) and not query_as. The .execute() method is available on the plain query builder, not on the typed variant.
help me fix a compiler error in my delete_post handler
Register all routes and test with curl
Your src/main.rs already registers all five handlers using .service() calls. Because actix-web uses routing macros (#[get], #[post], #[put], #[delete]), the path and method are defined directly on each handler function. No extra routing configuration is needed.
- Restart your server by pressing Ctrl+C in the terminal where it is running, then run:
cargo run
You should see the Connected to database log message confirming the server started successfully.
Compilation fails?
If you see errors, read the compiler message carefully. The most common issues are a missing import for UpdatePostRequest in src/handlers.rs or a mismatched function name between src/main.rs and your handler definitions.
help me fix compilation errors in my blog-api project
Now test the full CRUD cycle. Open a second terminal window to run curl commands while the server stays running.
- Create a new post by running:
curl -s -X POST http://127.0.0.1:8080/posts \
-H "Content-Type: application/json" \
-d '{"title": "My First Post", "body": "Hello from Rust!"}' | python3 -m json.tool
You should see a JSON response with the new post, including a generated id field. Copy that id value for the next commands.
- Update only the title of that post by running:
curl -s -X PUT http://127.0.0.1:8080/posts/[[POST_ID="the UUID from the create response"]] \
-H "Content-Type: application/json" \
-d '{"title": "Updated Title"}' | python3 -m json.tool
The response should show the updated title but the original body text unchanged. The updated_at timestamp should be newer than created_at.
- Delete the post by running:
curl -s -o /dev/null -w "%{http_code}" -X DELETE http://127.0.0.1:8080/posts/[[POST_ID="the same UUID you used above"]]
This prints only the HTTP status code. You should see 204 confirming the post was deleted.
- Confirm the post is gone by trying to fetch it:
curl -s http://127.0.0.1:8080/posts/[[POST_ID="the same UUID"]] | python3 -m json.tool
You should see a 404 response with the error message Post not found. Your full CRUD cycle is working.
Why use python3 -m json.tool?
Piping curl output to python3 -m json.tool pretty-prints the JSON response with indentation. Without it, the response appears as a single compressed line that is hard to read.
✔️ Awesome, I've got everything!
Great. Double check you have saved both src/models.rs and src/handlers.rs.
ⓧ I'd like to double check the full code
Here is the complete src/models.rs:
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
use uuid::Uuid;
#[derive(Debug, Serialize, FromRow)]
pub struct Post {
pub id: Uuid,
pub title: String,
pub body: String,
pub published: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Deserialize)]
pub struct CreatePostRequest {
pub title: String,
pub body: String,
pub published: Option<bool>,
}
#[derive(Debug, Deserialize)]
pub struct UpdatePostRequest {
pub title: Option<String>,
pub body: Option<String>,
pub published: Option<bool>,
}
Here is the complete src/handlers.rs:
use actix_web::{delete, get, post, put, web, HttpResponse};
use sqlx::query_as;
use uuid::Uuid;
use crate::models::{CreatePostRequest, Post, UpdatePostRequest};
use crate::AppState;
#[post("/posts")]
pub async fn create_post(
state: web::Data<AppState>,
body: web::Json<CreatePostRequest>,
) -> HttpResponse {
let published = body.published.unwrap_or(false);
let result = query_as::<_, Post>(
"INSERT INTO posts (title, body, published) VALUES ($1, $2, $3) RETURNING *"
)
.bind(&body.title)
.bind(&body.body)
.bind(published)
.fetch_one(&state.db)
.await;
match result {
Ok(post) => HttpResponse::Created().json(post),
Err(e) => {
log::error!("Failed to create post: {}", e);
HttpResponse::InternalServerError().json(serde_json::json!({
"error": "Failed to create post"
}))
}
}
}
#[get("/posts")]
pub async fn get_posts(state: web::Data<AppState>) -> HttpResponse {
let result = query_as::<_, Post>("SELECT * FROM posts ORDER BY created_at DESC")
.fetch_all(&state.db)
.await;
match result {
Ok(posts) => HttpResponse::Ok().json(posts),
Err(e) => {
log::error!("Failed to fetch posts: {}", e);
HttpResponse::InternalServerError().json(serde_json::json!({
"error": "Failed to fetch posts"
}))
}
}
}
#[get("/posts/{id}")]
pub async fn get_post(
state: web::Data<AppState>,
path: web::Path<Uuid>,
) -> HttpResponse {
let id = path.into_inner();
let result = query_as::<_, Post>("SELECT * FROM posts WHERE id = $1")
.bind(id)
.fetch_optional(&state.db)
.await;
match result {
Ok(Some(post)) => HttpResponse::Ok().json(post),
Ok(None) => HttpResponse::NotFound().json(serde_json::json!({
"error": "Post not found"
})),
Err(e) => {
log::error!("Failed to fetch post: {}", e);
HttpResponse::InternalServerError().json(serde_json::json!({
"error": "Failed to fetch post"
}))
}
}
}
#[put("/posts/{id}")]
pub async fn update_post(
state: web::Data<AppState>,
path: web::Path<Uuid>,
body: web::Json<UpdatePostRequest>,
) -> HttpResponse {
let id = path.into_inner();
let existing = query_as::<_, Post>("SELECT * FROM posts WHERE id = $1")
.bind(id)
.fetch_optional(&state.db)
.await;
let existing = match existing {
Ok(Some(post)) => post,
Ok(None) => {
return HttpResponse::NotFound().json(serde_json::json!({
"error": "Post not found"
}));
}
Err(e) => {
log::error!("Failed to fetch post: {}", e);
return HttpResponse::InternalServerError().json(serde_json::json!({
"error": "Failed to fetch post"
}));
}
};
let title = body.title.as_deref().unwrap_or(&existing.title);
let post_body = body.body.as_deref().unwrap_or(&existing.body);
let published = body.published.unwrap_or(existing.published);
let result = query_as::<_, Post>(
"UPDATE posts SET title = $1, body = $2, published = $3, updated_at = NOW() WHERE id = $4 RETURNING *"
)
.bind(title)
.bind(post_body)
.bind(published)
.bind(id)
.fetch_one(&state.db)
.await;
match result {
Ok(post) => HttpResponse::Ok().json(post),
Err(e) => {
log::error!("Failed to update post: {}", e);
HttpResponse::InternalServerError().json(serde_json::json!({
"error": "Failed to update post"
}))
}
}
}
#[delete("/posts/{id}")]
pub async fn delete_post(
state: web::Data<AppState>,
path: web::Path<Uuid>,
) -> HttpResponse {
let id = path.into_inner();
let result = sqlx::query("DELETE FROM posts WHERE id = $1")
.bind(id)
.execute(&state.db)
.await;
match result {
Ok(res) => {
if res.rows_affected() == 0 {
HttpResponse::NotFound().json(serde_json::json!({
"error": "Post not found"
}))
} else {
HttpResponse::NoContent().finish()
}
}
Err(e) => {
log::error!("Failed to delete post: {}", e);
HttpResponse::InternalServerError().json(serde_json::json!({
"error": "Failed to delete post"
}))
}
}
}
The src/main.rs file is unchanged from the previous step.
Your API now supports the full lifecycle of a blog post. Next up, you will tackle the Secret Mission and add filtering and pagination to the list endpoint.
Secret mission
Add Query-Parameter Filtering and Pagination
Your list endpoint returns every post in the database. Real APIs let clients filter by status and paginate through results. Can you add query-parameter support to GET /posts using actix-web's web::Query extractor?
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- PostgreSQL Docker container (running via compose.yaml).
- Docker volume pgdata (stores your blog database data).
- Local blog-api project folder on your Desktop.
Keep everything running
No action needed. Choose this if you're still actively building or experimenting with your blog API.
- Your PostgreSQL container stays running in the background and uses minimal resources.
- You can restart your API server at any time by running cargo run from the blog-api folder.
- Your blog posts remain stored in the database.
Pause - I'll come back to this later
Shut down running processes to free up memory. Your data and project files stay intact for when you return.
- Stop your running Rust server by pressing Ctrl+C in the terminal where it's running.
- Stop the PostgreSQL container by running this command from your blog-api folder:
docker compose stop
- Your database data is preserved in the Docker volume. When you're ready to resume, run docker compose start to bring the container back up.
Delete - I don't want to use this again
Remove all project resources and start fresh. This permanently deletes your database data and project files.
- Stop your running Rust server by pressing Ctrl+C in the terminal where it's running.
- Remove the Docker container and its data volume by running this command from your blog-api folder:
docker compose down -v
- Delete the project folder from your Desktop. On macOS or Linux:
cd ~/Desktop
rm -rf blog-api
- On Windows:
cd ~\Desktop
rmdir /s /q blog-api
- Confirm the folder is gone by running:
ls
- You should no longer see blog-api in the output.
Nice Work!
Nice Work!
Nice work! You just built a production-style blog REST API with actix-web, backed by PostgreSQL and powered by Rust's type system.
You've learned how to:
- Built a complete REST API with actix-web featuring 5 endpoints and type-safe extractors (web::Json, web::Path, web::Data) that eliminate runtime type errors.
- Configured shared application state with a PostgreSQL connection pool managed by Docker Compose with automatic schema initialization.
- Used serde and sqlx derive macros to make Rust structs serve as both your API schema and database model.
- Secret Mission: Added query-parameter filtering and pagination using the web::Query extractor.
Ready to quiz yourself?