一、概述
在前面几篇文章中,已经熟悉了 Actix-Web框架,各个组件。接下来实现一个博客 API,基于RESTful API风格,集成token验证。

二、项目结构
代码结构
新建一个项目blog-api,代码结构如下:
./
├── Cargo.toml
└── src
├── db.rs
├── handlers
│ ├── dev.rs
│ ├── mod.rs
│ ├── post_handler.rs
│ └── user_handler.rs
├── jwt.rs
├── main.rs
├── middleware
│ ├── auth.rs
│ └── mod.rs
└── models
├── mod.rs
├── post.rs
└── user.rs
表结构
数据库在阿里云上面,创建一个测试数据库
CREATE DATABASE rust_blog
CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci;
新建表users
CREATE TABLE `users` (
`id` bigint NOT NULL AUTO_INCREMENT,
`username` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
`password` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
`email` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`create_time` datetime DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
新建表posts
CREATE TABLE `posts` (
`id` bigint NOT NULL AUTO_INCREMENT,
`title` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
`content` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
`author_id` bigint NOT NULL,
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`update_time` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
依赖组件
Cargo.toml
[package]
name = "actix_swagger"
version = "0.1.0"
edition = "2024"
[dependencies]
actix-web = { version = "4.12", features = ["compress-gzip"] }
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1.0"
utoipa = { version = "5", features = ["actix_extras", "chrono"] }
utoipa-swagger-ui = { version = "9", features = ["actix-web"] }
log = "0.4" # 日志门面
env_logger = "0.11" # 控制台实现
# 异步 MySQL 驱动,支持 8.x
chrono = { version = "0.4", default-features = false, features = ["serde", "clock"] }
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "mysql", "chrono"] }
dotenvy = "0.15" # 读取 .env
futures-util = { version = "0.3", default-features = false, features = ["std"] }
actix-cors = "0.7"
md5 = "0.8" # 轻量、零配置
jsonwebtoken = { version = "10.2", features = ["rust_crypto"] }
数据模型
src/models/user.rs
use chrono::NaiveDateTime;
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
#[derive(Debug, Serialize, Deserialize, FromRow, utoipa::ToSchema)]
pub struct User {
pub id: i64,
pub username: String, // NOT NULL
#[serde(skip)]
#[allow(dead_code)]
pub password: String, // NOT NULL
pub email: Option<String>, // NULL -> Option
pub create_time: Option<NaiveDateTime>, // NULL -> Option
}
#[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct CreateUser {
pub username: String,
pub email: String,
pub password: String,
}
src/models/post.rs
use chrono::NaiveDateTime;
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
#[derive(Debug, Serialize, Deserialize, FromRow, utoipa::ToSchema)]
pub struct Post {
pub id: i64,
pub title: String,
pub content: String,
pub author_id: i64,
pub create_time: NaiveDateTime,
pub update_time: Option<NaiveDateTime>, // 允许 NULL
}
#[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct CreatePost {
pub title: String,
pub content: String,
}
数据库操作
src/db.rs
use sqlx::{mysql::MySqlPool};
use crate::models::{User, CreateUser, Post, CreatePost};
use md5;
pub async fn create_user(
pool: &MySqlPool,
user: CreateUser,
) -> Result<User, sqlx::Error> {
let mut tx = pool.begin().await?;
// 1. 插入
//计算 MD5(16 进制小写)
let password_hash = format!("{:x}", md5::compute(&user.password));
sqlx::query!(
r#"
INSERT INTO users (username, password, email, create_time)
VALUES (?, ?, ?, NOW())
"#,
user.username,
password_hash, // 已加密
user.email,
)
.execute(&mut *tx)
.await?;
// 2. LAST_INSERT_ID() 返回 u64,不需要 unwrap_or
let id: u64 = sqlx::query_scalar!("SELECT LAST_INSERT_ID()")
.fetch_one(&mut *tx)
.await?;
// 3. 查新行 ------ 明确列 NULL 性,与 User 结构体对应
let user = sqlx::query_as!(
User,
"SELECT id, username, password, email, create_time FROM users WHERE id = ?",
id as i64
)
.fetch_one(&mut *tx)
.await?;
tx.commit().await?;
Ok(user)
}
pub async fn get_user_by_id(pool: &MySqlPool, id: i64) -> Result<Option<User>,sqlx::Error> {
let user = sqlx::query_as::<_, User>(
"SELECT id, username, email, create_time, '' as password FROM users WHERE id = ?"
)
.bind(id)
.fetch_optional(pool)
.await?;
Ok(user)
}
pub async fn create_post(
pool: &MySqlPool,
post: CreatePost,
author_id: i64,
) -> Result<Post, sqlx::Error> {
let mut tx = pool.begin().await?;
// 1. 插入
sqlx::query!(
r#"
INSERT INTO posts (title, content, author_id, create_time, update_time)
VALUES (?, ?, ?, NOW(), NULL)
"#,
post.title,
post.content,
author_id
)
.execute(&mut *tx)
.await?;
// 2. 取新 id
let id: u64 = sqlx::query_scalar!("SELECT LAST_INSERT_ID()")
.fetch_one(&mut *tx)
.await?;
// 3. 再查整行
let new_post = sqlx::query_as!(
Post,
"SELECT id, title, content, author_id, create_time, update_time
FROM posts WHERE id = ?",
id as i64
)
.fetch_one(&mut *tx)
.await?;
tx.commit().await?;
Ok(new_post)
}
pub async fn get_posts(pool: &MySqlPool, limit: i64) -> Result<Vec<Post>,sqlx::Error> {
let posts = sqlx::query_as::<_, Post>(
"SELECT id, title, content, author_id, create_time, update_time
FROM posts
ORDER BY create_time DESC
LIMIT ?"
)
.bind(limit)
.fetch_all(pool)
.await?;
Ok(posts)
}
pub async fn get_users(pool: &MySqlPool, limit: i64) -> Result<Vec<User>,sqlx::Error> {
let users = sqlx::query_as::<_, User>(
"SELECT id, username, '' as password, email, create_time
FROM users
ORDER BY create_time DESC
LIMIT ?"
)
.bind(limit)
.fetch_all(pool)
.await?;
Ok(users)
}
/// 根据ID获取单个帖子
pub async fn get_post_by_id(pool: &MySqlPool, id: i64) -> Result<Option<Post>,sqlx::Error> {
let post = sqlx::query_as::<_, Post>(
"SELECT id, title, content, author_id, create_time, update_time
FROM posts WHERE id = ?"
)
.bind(id)
.fetch_optional(pool)
.await?;
Ok(post)
}
环境变量
.env
DATABASE_URL=mysql://root:123456@localhost:3306/rust_blog
注意:如果密码带有@符号,需要进行URL-encode编码
打开在线url编码器,链接:https://www.convertstring.com/zh_CN/EncodeDecode/UrlEncode
请求处理器
src/handlers/user_handler.rs
use actix_web::{web, HttpResponse, Result};
use sqlx::{mysql::MySqlPool};
use crate::{db, models::CreateUser};
use crate::models::User;
// 用户相关API接口模块
// 提供用户创建和查询功能
/// 创建新用户接口
///
/// 用于注册新用户,创建用户账号并返回用户信息
#[utoipa::path(
post,
path = "/api/users",
request_body = CreateUser,
description = "注册新用户账号",
summary = "创建用户",
responses(
(status = 200, description = "用户创建成功", body = User),
(status = 400, description = "请求参数格式错误或用户已存在"),
(status = 500, description = "服务器内部错误")
),
tag = "用户管理"
)]
pub async fn create_user_handler(
pool: web::Data<MySqlPool>,
user: web::Json<CreateUser>,
) -> Result<HttpResponse> {
match db::create_user(pool.get_ref(), user.into_inner()).await {
Ok(user) => Ok(HttpResponse::Created().json(user)),
Err(e) => Ok(HttpResponse::InternalServerError().json(serde_json::json!({
"error": e.to_string()
})))
}
}
/// 根据ID获取用户信息接口
///
/// 通过用户ID查询特定用户的详细信息
#[utoipa::path(
get,
path = "/api/users/{id}",
description = "根据用户ID获取用户详细信息",
summary = "查询用户详情",
responses(
(status = 200, description = "查询成功", body = User),
(status = 404, description = "用户不存在"),
(status = 500, description = "服务器内部错误")
),
params(
("id" = i64, Path, description = "用户ID,用于唯一标识用户")
),
tag = "用户管理"
)]
pub async fn get_user_handler(
pool: web::Data<MySqlPool>,
user_id: web::Path<i64>,
) -> Result<HttpResponse> {
match db::get_user_by_id(pool.get_ref(), user_id.into_inner()).await {
Ok(Some(user)) => Ok(HttpResponse::Ok().json(user)),
Ok(None) => Ok(HttpResponse::NotFound().json(serde_json::json!({
"error": "User not found"
}))),
Err(e) => Ok(HttpResponse::InternalServerError().json(serde_json::json!({
"error": e.to_string()
})))
}
}
/// 获取用户列表接口
///
/// 获取系统中的用户列表,支持分页查询
#[utoipa::path(
get,
path = "/api/users",
description = "获取用户列表,支持通过limit参数限制返回数量",
summary = "查询用户列表",
responses(
(status = 200, description = "查询成功", body = [User]),
(status = 500, description = "服务器内部错误")
),
params(
("limit" = Option<i64>, Query, description = "限制返回数量,默认为10"),
("page" = Option<i64>, Query, description = "页码,从1开始")
),
tag = "用户管理"
)]
pub async fn get_users_handler(
pool: web::Data<MySqlPool>,
query: web::Query<std::collections::HashMap<String, String>>,
) -> Result<HttpResponse> {
let limit = query.get("limit")
.and_then(|s| s.parse().ok())
.unwrap_or(10);
match db::get_users(pool.get_ref(), limit).await {
Ok(users) => Ok(HttpResponse::Ok().json(users)),
Err(e) => Ok(HttpResponse::InternalServerError().json(serde_json::json!({
"error": e.to_string()
})))
}
}
src/handlers/post_handler.rs
use sqlx::{mysql::MySqlPool};
use crate::models::{CreatePost, Post}; // 模型
use crate::db; // 数据库函数
use std::collections::HashMap; // HashMap
use actix_web::{web, HttpRequest, HttpMessage,HttpResponse}; // 解决 extensions() 不可见
// 帖子相关API接口模块
// 提供帖子创建和查询功能
/// 创建新帖子接口
///
/// 允许认证用户创建新的帖子内容,帖子将与当前认证用户关联
#[utoipa::path(
post,
path = "/api/posts",
request_body = CreatePost,
description = "创建新的博客帖子,需要用户认证",
summary = "创建帖子",
responses(
(status = 200, description = "帖子创建成功", body = Post),
(status = 400, description = "请求参数格式错误"),
(status = 404, description = "未找到相关资源"),
(status = 500, description = "服务器内部错误")
),
tag = "帖子管理"
)]
pub async fn create_post_handler(
pool: web::Data<MySqlPool>,
post: web::Json<CreatePost>,
req: HttpRequest,
) -> Result<HttpResponse, actix_web::Error> {
// 从请求扩展中获取认证的用户ID
let author_id = req.extensions().get::<i64>().copied().unwrap_or(1);
println!("author_id: {}", author_id);
match db::create_post(pool.get_ref(), post.into_inner(), author_id).await {
Ok(post) => Ok(HttpResponse::Created().json(post)),
Err(e) => Ok(HttpResponse::InternalServerError().json(serde_json::json!({
"error": e.to_string()
})))
}
}
/// 获取帖子列表接口
///
/// 获取系统中的帖子列表,支持分页查询
#[utoipa::path(
get,
path = "/api/posts",
description = "获取帖子列表,支持通过limit参数限制返回数量",
summary = "查询帖子列表",
responses(
(status = 200, description = "查询成功", body = [Post]),
(status = 500, description = "服务器内部错误")
),
params(
("limit" = Option<i64>, Query, description = "限制返回数量,默认为10"),
("page" = Option<i64>, Query, description = "页码,从1开始")
),
tag = "帖子管理"
)]
pub async fn get_posts_handler(
pool: web::Data<MySqlPool>,
query: web::Query<HashMap<String, String>>,
) -> Result<HttpResponse, actix_web::Error> {
let limit = query.get("limit")
.and_then(|s| s.parse().ok())
.unwrap_or(10);
match db::get_posts(pool.get_ref(), limit).await {
Ok(posts) => Ok(HttpResponse::Ok().json(posts)),
Err(e) => Ok(HttpResponse::InternalServerError().json(serde_json::json!({
"error": e.to_string()
})))
}
}
/// 获取帖子详情接口
///
/// 根据帖子ID获取单个帖子的详细信息
#[utoipa::path(
get,
path = "/api/posts/{id}",
description = "根据ID获取单个帖子的详细信息",
summary = "查询帖子详情",
responses(
(status = 200, description = "查询成功", body = Post),
(status = 404, description = "帖子不存在"),
(status = 500, description = "服务器内部错误")
),
params(
("id" = i64, Path, description = "帖子ID", example = 1)
),
tag = "帖子管理"
)]
pub async fn get_post_handler(
pool: web::Data<MySqlPool>,
id: web::Path<i64>,
) -> Result<HttpResponse, actix_web::Error> {
match db::get_post_by_id(pool.get_ref(), *id).await {
Ok(Some(post)) => Ok(HttpResponse::Ok().json(post)),
Ok(None) => Ok(HttpResponse::NotFound().json(serde_json::json!({
"error": "帖子不存在"
}))),
Err(e) => Ok(HttpResponse::InternalServerError().json(serde_json::json!({
"error": e.to_string()
})))
}
}
src/handlers/dev.rs
use actix_web::{get, web, HttpResponse, Responder};
use serde::{Serialize};
use utoipa::{ToSchema};
#[derive(Serialize, ToSchema)]
struct TokenReply {
message: String,
}
// 内部函数,处理实际的token生成逻辑
fn generate_token(user_id: u32) -> String {
let user_id_i64: i64 = user_id as i64;
let t = crate::jwt::make_token(user_id_i64, 24);
format!("Bearer {}", t)
}
/// 生成开发测试token(指定用户ID)
///
/// 用于开发环境测试时快速生成认证令牌,使用指定的用户ID
#[utoipa::path(
get,
path = "/dev/token/{user_id}",
responses(
(status = 200, description = "成功生成测试token", body = TokenReply)
),
tag = "开发工具"
)]
#[get("/token/{user_id:[0-9]+}")]
pub async fn dev_token(user_id: web::Path<u32>) -> impl Responder {
let token = generate_token(*user_id);
HttpResponse::Ok().body(token)
}
/// 生成开发测试token(默认用户ID=1)
///
/// 用于开发环境测试时快速生成认证令牌,使用默认用户ID=1
#[utoipa::path(
get,
path = "/dev/token",
responses(
(status = 200, description = "成功生成测试token", body = TokenReply)
),
tag = "开发工具"
)]
#[get("/token")]
pub async fn dev_token_default() -> impl Responder {
// 直接调用内部函数生成token,使用默认user_id=1
let token = generate_token(1);
HttpResponse::Ok().body(token)
}
src/handlers/mod.rs
// 把子模块引进来
pub mod user_handler;
pub mod post_handler;
pub mod dev;
// 再导出给 main.rs 用
pub use user_handler::{create_user_handler, get_user_handler, get_users_handler};
pub use post_handler::{get_posts_handler, create_post_handler, get_post_handler};
中间件
src/middleware/auth.rs
use actix_web::{
dev::{forward_ready, Service, ServiceRequest, ServiceResponse, Transform},
Error, HttpMessage,
};
use futures_util::future::LocalBoxFuture;
use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation};
use crate::jwt::{Claims, SECRET};
pub struct AuthMiddleware;
pub struct AuthMiddlewareService<S> {
service: S,
}
impl<S, B> Transform<S, ServiceRequest> for AuthMiddleware
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error>,
S::Future: 'static,
B: 'static,
{
type Response = ServiceResponse<B>;
type Error = Error;
type InitError = ();
type Transform = AuthMiddlewareService<S>;
type Future = std::future::Ready<Result<Self::Transform, Self::InitError>>;
fn new_transform(&self, service: S) -> Self::Future {
std::future::ready(Ok(AuthMiddlewareService { service }))
}
}
impl<S, B> Service<ServiceRequest> for AuthMiddlewareService<S>
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error>,
S::Future: 'static,
B: 'static,
{
type Response = ServiceResponse<B>;
type Error = Error;
type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
forward_ready!(service);
fn call(&self, req: ServiceRequest) -> Self::Future {
let token = req
.headers()
.get("Authorization")
.and_then(|h| h.to_str().ok())
.and_then(|s| s.strip_prefix("Bearer "));
let claims = match token {
Some(t) => match decode::<Claims>(
t,
&DecodingKey::from_secret(SECRET),
&Validation::new(Algorithm::HS256),
) {
Ok(data) => data.claims,
Err(_) => return Box::pin(async {
Err(actix_web::error::ErrorUnauthorized("bad token"))
}),
},
None => return Box::pin(async {
Err(actix_web::error::ErrorUnauthorized("missing token"))
}),
};
req.extensions_mut().insert(claims.user_id);
let fut = self.service.call(req);
Box::pin(async move { fut.await })
}
}
src/middleware/mod.rs
pub mod auth; // 告诉编译器去同级目录找 auth.rs
jwt认证
use chrono::Utc;
use jsonwebtoken::{encode, EncodingKey, Header};
pub const SECRET: &[u8] = b"!ChangeMe!"; // 与验证端保持一致
#[derive(Debug, serde::Serialize, serde::Deserialize)]
pub struct Claims {
pub user_id: i64,
pub exp: i64, // 过期时间(UTC 时间戳)
}
/// 手动生成一个有效期为 `hours` 小时的 Token
pub fn make_token(user_id: i64, hours: i64) -> String {
let exp = Utc::now()
.checked_add_signed(chrono::Duration::hours(hours))
.unwrap()
.timestamp();
let claims = Claims { user_id, exp };
encode(
&Header::default(),
&claims,
&EncodingKey::from_secret(SECRET),
)
.unwrap()
}
主程序
mod models;
mod handlers;
mod db;
mod middleware;
use actix_web::{web, App, HttpServer, middleware::Logger,middleware::Compress};
use sqlx::{mysql::MySqlPoolOptions};
use env_logger::Env;
use dotenvy::dotenv;
mod jwt;
use middleware::auth::AuthMiddleware;
use handlers::dev::{dev_token, dev_token_default};
// 导入必要的类型
use utoipa::OpenApi;
use utoipa_swagger_ui::SwaggerUi;
// 使用derive宏实现OpenAPI
#[derive(OpenApi)]
#[openapi(
info(
title = "博客 API",
description = "一个使用Actix Web框架构建的RESTful API示例,用于博客文章的管理",
version = "1.0.0"
),
// 明确列出所有路径
paths(
handlers::user_handler::create_user_handler,
handlers::user_handler::get_user_handler,
handlers::user_handler::get_users_handler,
handlers::post_handler::get_posts_handler,
handlers::post_handler::create_post_handler,
handlers::post_handler::get_post_handler,
handlers::dev::dev_token,
handlers::dev::dev_token_default
)
)]
pub struct ApiDoc;
#[actix_web::main]
async fn main() -> std::io::Result<()> {
dotenv().ok(); // 加载 .env 到环境变量
// 初始化日志
env_logger::init_from_env(Env::default().default_filter_or("info"));
log::info!("Starting HTTP server on http://127.0.0.1:8080");
// 建立连接池
let db_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
let pool = MySqlPoolOptions::new()
.max_connections(5)
.connect(&db_url)
.await
.expect("Failed to create MySqlPool");
HttpServer::new(move || {
App::new()
.wrap(Logger::default())
.wrap(Compress::default())
// 1. 不需要认证的接口
.service(
web::scope("/dev")
.service(dev_token)
.service(dev_token_default)
)
.app_data(web::Data::new(pool.clone()))
// 使用utoipa自带的Swagger UI
.service(
SwaggerUi::new("/swagger-ui/{_:.*}")
.url("/api-docs/openapi.json", ApiDoc::openapi())
)
.service(
web::scope("/api")
.wrap(AuthMiddleware) // 认证只拦截 /api/*
.service(
web::scope("/users")
.route("", web::post().to(handlers::create_user_handler))
.route("", web::get().to(handlers::get_users_handler))
.route("/{id}", web::get().to(handlers::get_user_handler))
)
.service(
web::scope("/posts")
.route("", web::get().to(handlers::get_posts_handler))
.route("", web::post().to(handlers::create_post_handler))
.route("/{id}", web::get().to(handlers::get_post_handler))
)
)
})
.bind(("127.0.0.1", 8080))?
.workers(4) // 工作线程数
.run()
.await
}
注意:这里设置的连接池大小为5,生产环境,请根据实际情况修改。
三、博客 API调用
获取token
首先要获取token,user_id=1,有效期24小时
使用postman进行调用,http://localhost:8080/dev/token

用户管理
创建用户
调用接口:http://localhost:8080/dev/token
指定token,输入上一步返回的的token

指定Content-Type:application/json

指定请求参数,创建用户Alice
{
"username":"Alice",
"password":"123456",
"email":"alice@example.com"
}
选择body,输入json参数,发送请求,就可以得到返回200,说明成功了

再创建一个用户,覆盖请求参数,再次发送
{
"username":"Bob",
"password":"123456",
"email":"bob@example.com"
}
查询用户列表
访问链接http://localhost:8080/api/users,使用get请求,效果如下

查询用户详情
根据用户id查询

帖子管理
创建帖子
输入json参考,请求接口:http://localhost:8080/api/posts
{
"title":"xiao1",
"content":"abcd1"
}
效果如下

再创建一个帖子
{
"title":"xiao2",
"content":"abcd2"
}
查询帖子列表
请求接口:http://localhost:8080/api/posts

查询帖子详情
请求接口:http://localhost:8080/api/posts

本文参考链接:https://blog.csdn.net/sinat_41617212/article/details/154069236