前言#
昨天我们实现了用户相关的接口,今天我们来实现文章相关的接口
项目地址:
https://github.com/1714080902120/rust_rocket_crud_demo由于我是先实现完再写的这篇文章,如果有些地方无法运行,可以看下我项目里的代码。
目前我还在往全栈的方向学习,所以如果看的不顺眼,请多多包涵。如果觉得那里可以改进,麻烦评论区说下,谢谢~
增/改#
增和改的逻辑基本上都是一样的,所以复用同一个接口即可。
实现#
我们现在src文件夹下创建article文件夹,然后在里面分别创建四个文件

注意这里的db_service以及route和auth里的名字是一样的,但是并不会有冲突,因为它们都只会用在文件夹里面。
然后我们在mod.rs中引入它们
mod db_service;
mod file_operate;
pub mod route; 接着我们先去到type/mod.rs中新增一个类型,这个类型用于存储token里用户的id,这么做是为了校验是否是作者本人。如果不是,直接扔给400。
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct UserMsg {
pub id: String,
}
#[rocket::async_trait]
impl<'r> FromRequest<'r> for UserMsg {
type Error = String;
async fn from_request(req: &'r Request<'_>) -> Outcome<Self, Self::Error> {
if req
.local_cache(|| AuthMsg {
is_valid_token: false,
})
.is_valid_token
{
let my_config = req
.rocket()
.state::<MyConfig>()
.expect("get global custom config error in fairing");
let token_field = my_config.token_field.as_str();
let token_key = my_config.token_key.as_str();
let header = req.headers();
let token_data = header.get(token_field).next();
if let Some(token) = token_data {
let token = decode_token(token, token_key).unwrap();
let id = token.claims.id;
return Outcome::Success(UserMsg { id });
} else {
return Outcome::Failure((
Status::BadRequest,
String::from("user no login or token expired"),
));
}
} else {
return Outcome::Failure((
Status::BadRequest,
String::from("user no login or token expired"),
));
}
}
} 然后我们为增/改新增一个类型
#[derive(Debug, Eq, PartialEq, Serialize, Deserialize, FromForm)]
pub struct SetArticleData {
#[field(name = "id")]
id: String,
#[field(name = "title")]
title: String,
#[field(name = "description")]
description: String,
#[field(name = "content")]
content: String,
#[field(name = "is_publish")]
is_publish: bool,
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
pub enum SetArticleDataState {
Success(u64),
Fail(()),
}
impl <'r>Responder<'r, 'static> for RtData<SetArticleDataState> {
fn respond_to(self, _: &'r Request<'_>) -> response::Result<'static> {
let data = self.to_string();
Response::build()
.header(ContentType::JSON)
.sized_body(data.len(), Cursor::new(data))
.ok()
}
}SetArticleData:这个用来和post传输过来的form对应。SetArticleDataState:这个作为Response时和RtData组装的类型。这里我只返回修改的时间。
然后回到route.rs中直接上代码
#[post("/set_article", data = "<set_article_data>")]
pub async fn set_article(
db: BlogDBC,
set_article_data: Form<SetArticleData>,
user_msg: UserMsg,
my_config: &State<MyConfig>,
) -> Result<RtData<SetArticleDataState>, Status> {
let article = set_article_data.into_inner();
let title = article.title;
let description = article.description;
let save_content = format!("# {title}\n") + article.content.as_str();
let content_buf: &[u8] = save_content.as_ref();
let user_id = user_msg.id;
let is_publish = article.is_publish;
let is_add = article.id.is_empty();
let article_id = if is_add {
Uuid::new_v4().to_string()
} else {
article.id
};
let dir_path_name = format!("md/{user_id}");
let dir_path = Path::new(&dir_path_name);
match fs::read_dir(&dir_path).await {
Ok(_) => (),
Err(_) => {
match fs::create_dir(dir_path).await {
Ok(_) => (),
Err(_) => {
return Err(Status::InternalServerError);
}
};
}
};
write_into_md(&user_id, &article_id, content_buf).await?;
match save_article(db, is_add, user_id, article_id, title, &description, is_publish).await {
Ok(modifiy_time) => {
return Ok(RtData {
success: true,
rt: Rt::Success,
msg: String::from("set article success"),
data: SetArticleDataState::Success(modifiy_time),
});
}
Err((err, _modify_time)) => {
dbg!(&err);
match err {
SqlxError::RowNotFound => {
return Ok(RtData {
success: true,
rt: Rt::Success,
msg: String::from("set article success"),
data: SetArticleDataState::Success(_modify_time),
});
}
_ => {
return Ok(RtData {
success: false,
rt: Rt::Fail,
msg: String::from("set article fail"),
data: SetArticleDataState::Fail(()),
})
}
};
}
}
}代码稍微有些长,但是非常好理解。
就是拿到数据后组装md文件的存储路径,将它存储在src同级的md文件夹里,以md/${author_id}/${article_id}作为路径。
如果id不为空,那么就是set,否则为add。add的场景会创建新的.md文件。
我们来实现这里的save_article,回到article/db_service里面
pub async fn save_article(
mut db: BlogDBC,
is_add: bool,
author_id: String,
article_id: String,
title: String,
description: &str,
is_publish: bool,
) -> Result<u64, (SqlxError, u64)> {
let modify_time = get_current_timestamp();
let sql = if is_add {
format!("INSERT INTO public.article (id, title, author_id, description, modify_time, is_publish) VALUES ('{article_id}', '{title}', '{author_id}', '{description}', {modify_time}, {is_publish})")
} else {
format!("UPDATE public.article SET title = '{title}', description = '{description}', modify_time = {modify_time}, is_publish = {is_publish} WHERE id = '{article_id}'")
};
dbg!(&sql);
match sqlx::query(&sql).fetch_one(&mut *db).await {
Ok(_) => Ok(modify_time),
Err(err) => Err((err, modify_time)),
}
} 在这一步创建时间戳,表示最后的修改时间,插入/修改成功之后返回modify_time。
然后我们去到file_operate文件中
use rocket::{http::Status, tokio::fs};
use std::path::Path;
pub async fn write_into_md(
user_id: &str,
article_id: &str,
content_buf: &[u8],
) -> Result<(), Status> {
let file_path = format_md_path(user_id, article_id);
match fs::write(Path::new(&file_path), content_buf).await {
Err(err) => {
dbg!(err);
return Err(Status::InternalServerError);
}
Ok(_) => return Ok(()),
}
}
pub async fn read_md_into_str(user_id: &str, article_id: &str) -> Result<String, Status> {
let file_path = format_md_path(user_id, article_id);
match fs::read_to_string(Path::new(&file_path)).await {
Err(err) => {
dbg!(err);
return Err(Status::InternalServerError);
}
Ok(content) => return Ok(content),
}
}
pub fn format_md_path(user_id: &str, article_id: &str) -> String {
format!("md/{user_id}/{article_id}.md")
}
方法很简单,就是对文件的读和写,如果中间过程出错直接return Err(500)。
然后我们去到main.rs中注册
// ...
.mount("/user", routes![login, register, get_user_article, set_article])
// ... 测试#
第一步我们需要登录,拿到_token再进行下一步新增文章
拿到token之后放到Header的_token字段中。

然后我们再调用/user/set_article

然后回过头来看文件夹中多了一个.md文件

那么插入就成功了,如果不放心,可以去数据库里看下

然后我们再来测试下修改

把id也带上


测试正常
删#
实现#
删除的就比较简单了
#[delete("/del_article?<id>")]
pub async fn del_article(
db: BlogDBC,
id: String,
user_msg: UserMsg,
) -> Result<RtData<DefaultSuccessData>, Status> {
let user_id = user_msg.id;
match try_delete_article(db, id, user_id).await {
Ok(state) => {
if state {
Ok(RtData {
success: true,
rt: Rt::Success,
data: DefaultSuccessData(()),
msg: String::from("delete success !"),
})
} else {
Ok(RtData {
success: false,
rt: Rt::Fail,
data: DefaultSuccessData(()),
msg: String::from("delete fail, 有内鬼,终止交易!"),
})
}
}
Err(err) => {
dbg!(err);
return Err(Status::InternalServerError);
}
}
}我这里只是删除该id在数据里的索引,文章内容还是保留着。
当然,还是得判断是否是同一个用户。
去到article/db_service中
pub async fn try_delete_article(
mut db: BlogDBC,
id: String,
author_id: String,
) -> DbQueryResult<bool> {
let query_sql = format!(
"SELECT * FROM public.article WHERE id = '{id}' AND author_id = '{author_id}' LIMIT 1"
);
match sqlx::query(&query_sql).fetch_one(&mut *db).await {
Ok(_) => {
let sql = format!(
"DELETE FROM public.article WHERE id = '{id}' AND author_id = '{author_id}'"
);
dbg!(&sql);
match sqlx::query(&sql).fetch_one(&mut *db).await {
Ok(_) => Ok(true),
Err(e) => match e {
SqlxError::RowNotFound => Ok(true),
_ => return Err(e),
},
}
}
Err(err) => match err {
SqlxError::RowNotFound => Ok(false),
_ => return Err(err),
},
}
} 先查询下对应的文章是否是改用户写的,然后再删除这一列。
别忘了去注册路由,我这里就不贴代码了。
测试#
记得先拿到_token

我们看下数据库里是否还存在该条信息

可以看到没了
获取文章详情#
实现#
老样子,这个也是需要获得用户的id用于匹配是否是作者本人,这样我们可以判断该用户是否可以对文章进行编辑
我们先实现文章细节的类型,去到article/mod.rs中
#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ArticleDetail {
id: String,
title: String,
content: String,
modify_time: u64,
can_edit: bool
}
#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum ArticleDetailData {
Success(ArticleDetail),
Fail
}
impl <'r>Responder<'r, 'static> for RtData<ArticleDetailData> {
fn respond_to(self, _: &'r Request<'_>) -> response::Result<'static> {
let data = self.to_string();
Response::build()
.header(ContentType::JSON)
.sized_body(data.len(), Cursor::new(data))
.ok()
}
}这一套轻车熟路,就不用我都说了。
然后我们回到article/route中实现路由
#[get("/article_detail?<id>")]
pub async fn get_article_detail(
db: BlogDBC,
id: &str,
user_msg: UserMsg,
) -> Result<RtData<ArticleDetailData>, Status> {
match article_detail(db, id).await {
Ok(row) => {
let author_id: String = row.get::<Uuid, usize>(3).to_string();
let content = read_md_into_str(&user_msg.id, &id).await?;
let article_detail = ArticleDetail {
id: row.get::<Uuid, usize>(0).to_string(),
title: row.get(1),
content,
modify_time: row.get::<i64, usize>(2) as u64,
can_edit: author_id == user_msg.id,
};
Ok(RtData {
success: true,
rt: Rt::Success,
data: ArticleDetailData::Success(article_detail),
msg: String::from("get detail success"),
})
}
Err(err) => {
if is_row_not_found(err) {
return Ok(RtData {
success: false,
rt: Rt::Fail,
data: ArticleDetailData::Fail,
msg: String::from("not found"),
});
} else {
return Err(Status::InternalServerError);
}
}
}
} 然后去到article/db_service中
pub async fn article_detail(mut db: BlogDBC, id: &str) -> DbQueryResult<PgRow> {
let sql = format!("SELECT id, title, modify_time, author_id FROM public.article WHERE id = '{id}' AND is_publish = true");
let res = sqlx::query(&sql).fetch_one(&mut *db).await?;
Ok(res)
}另外db/mod.rs中补充下一些内容
use rocket_db_pools::{sqlx::PgPool, Connection, Database, Initializer};
// ...
pub type SqlxError = sqlx::Error;
pub type DbQueryResult<T> = Result<T, sqlx::Error>;
pub type BlogDBC = Connection<Blog>;
pub fn is_row_not_found(err: SqlxError) -> bool {
match err {
SqlxError::RowNotFound => true,
_ => false,
}
}
对于一些常用类型的封装
别忘了去注册路由~
测试#
。。。刚刚就不应该先测试删除的。。。
这样就只能重复 登录 -> 新增 的操作了。

获取成功
分页查询#
实现#
这个接口我们可以和/根路径接口重合,根路径重定向去到我们的分页查询路由
老样子,我们先补充下类型,去到type/mod.rs
#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
pub struct ArticleData {
pub list: Vec<Article>,
}
impl<'r> Responder<'r, 'static> for RtData<ArticleData> {
fn respond_to(self, _: &'r Request<'_>) -> response::Result<'static> {
let data = self.to_string();
Response::build()
.header(ContentType::JSON)
.sized_body(data.len(), Cursor::new(data))
.ok()
}
} 然后回到我们的路由文件
#[get("/")]
pub fn index() {
Redirect::to(uri!("/get_article?all=true&id=-1&author_id=-1"));
}
/// get article
#[get("/get_article?<all>&<id>&<limit>&<page_no>")]
pub async fn route_article(
db: BlogDBC,
all: bool,
id: &str,
limit: i32,
page_no: i32,
) -> Result<RtData<ArticleData>, Status> {
let limit = if limit < 0 { 20 } else { limit };
let result = get_article(db, all, id, limit, page_no).await;
match result {
Ok(v) => {
let articles = v.iter().map(|row| Article {
id: row.get::<Uuid, usize>(0).to_string(),
title: row.get(1),
description: row.get(2),
modify_time: row.get::<i64, usize>(4) as u64,
author_name: row.get(5),
author_desc: row.get(6),
});
Ok(RtData {
success: true,
msg: String::from("get all article success!"),
rt: Rt::Success,
data: ArticleData {
list: articles.collect(),
},
})
}
Err(err) => {
println!("query all article error, {}", err);
Err(Status::InternalServerError)
}
}
}
对于/路径,直接Redirect[1]到get_article路径
然后去到db_service中
pub async fn get_article(
mut db: BlogDBC,
all: bool,
id: &str,
limit: i32,
page_no: i32,
) -> DbQueryResult<Vec<PgRow>> {
let mut sql = String::from("SELECT a.id, a.title, a.description, a.author_id, a.modify_time, b.name, b.description FROM public.article AS a LEFT JOIN public.user AS b ON a.author_id = b.id WHERE a.is_publish = true");
let offset = page_no * limit;
if !all {
sql += match id.is_empty() {
false => {
format!(" AND a.id = '{id}'")
}
_ => String::from(""),
}
.as_str();
}
sql += format!(" LIMIT {limit} OFFSET {offset}").as_str();
dbg!(&sql);
sqlx::query(&sql).fetch_all(&mut *db).await
} 如果all为true,则分页查询,如果all为false,id不为空,则查询单个。
呃。。。这个函数有些问题。。。对于查询单个的场景没有做limit移除处理。。。只能由前端自行调整了。
另外有个点需要注意,那就是只有is_publish为true的文章才能被这个接口查询到。
而is_publish为false则表示是草稿状态。
注册完之后我们来测试下
测试#
获取单个

获取多个

正常。
模糊查询#
实现#
这个主要是用在搜索栏里的,这个东西要搞可以搞到非常非常复杂,比如深度神经学习、大数据什么的。。
而我这里只是个demo,简单的通过字符串匹配
老规矩先上类型,article/mod.rs中
#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum GetArticleData {
Success(ArticleData),
Fail,
}
impl<'r> Responder<'r, 'static> for RtData<GetArticleData> {
fn respond_to(self, _: &'r Request<'_>) -> response::Result<'static> {
let data = self.to_string();
Response::build()
.header(ContentType::JSON)
.sized_body(data.len(), Cursor::new(data))
.ok()
}
}然后回到route.rs中
#[get("/search?<condition>")]
pub async fn search_article(
db: BlogDBC,
condition: &str,
) -> Result<RtData<GetArticleData>, Status> {
match try_search_article(db, condition).await {
Ok(rows) => Ok(RtData {
success: true,
rt: Rt::Success,
data: GetArticleData::Success(ArticleData {
list: rows.iter().map(|row| Article {
id: row.get::<Uuid, usize>(0).to_string(),
title: row.get(1),
modify_time: row.get::<i64, usize>(2) as u64,
description: row.get(3),
author_name: row.get(4),
author_desc: row.get(5),
}).collect(),
}),
msg: String::from("search success"),
}),
Err(err) => {
dbg!(&err);
if is_row_not_found(err) {
Ok(RtData {
success: false,
rt: Rt::Fail,
msg: String::from("not found"),
data: GetArticleData::Fail,
})
} else {
return Err(Status::InternalServerError);
}
}
}
} 然后db_service
pub async fn try_search_article(mut db: BlogDBC, condition: &str) -> DbQueryResult<Vec<PgRow>> {
let sql = format!("SELECT a.id, a.title, a.modify_time, a.description, b.name, b.description FROM public.article AS a LEFT JOIN public.user AS b ON a.author_id = b.id WHERE a.is_publish = true AND (a.title::TEXT LIKE '%{condition}%' OR a.description::TEXT LIKE '%{condition}%')");
let res = sqlx::query(&sql).fetch_all(&mut *db).await?;
dbg!(sql);
Ok(res)
} 这里根据的是pgsql提供的%[key]%来模糊查询
测试#

正常
总结#
那么到这里就差不多了,这一个demo主要是带大家(包括我自己)入门以及熟悉下rust代码的编写
实际上很多东西我都没有去实现,比如:
- 接入
log日志 - 类型整理,
RtData的类型太多了,导致重复的实现太多了,实际上可以整合成一个枚举类型 - 缓存,
redis - 优化
io操作,每次set/add都是io操作,这样会有很大的性能问题 sql语句优化- 其余接口实现,比如获取用户自己的草稿列表等。
最后如果你觉得这些文章对你有帮助,请务必点个赞~
吐槽:卧槽了,公司项目的重构是真的恶心,本来还以为能划水,现在搞得还不如做需求呢。。。
参考#
发布于 2023-06-07 18:01・IP 属地广东
