BLOG-43 Post related api endpoints #55

Merged
squid merged 9 commits from BLOG-43_post_crud_api into main 2025-06-07 21:26:10 +08:00
23 changed files with 1172 additions and 80 deletions
Showing only changes of commit e72f5a5a8e - Show all commits

View File

@ -2,7 +2,10 @@
"cSpell.words": [
"actix",
"chrono",
"dotenv",
"rustls",
"serde",
"sqlx",
"squidspirit"
],
"java.project.sourcePaths": [
zoe marked this conversation as resolved Outdated
Outdated
Review

Please remove it

Please remove it

2
backend/.gitignore vendored
View File

@ -1 +1,3 @@
.env
/.sqlx
/target

987
backend/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -10,8 +10,17 @@ edition = "2024"
actix-web = "4.10.2"
async-trait = "0.1.88"
chrono = "0.4.41"
dotenv = "0.15.0"
env_logger = "0.11.8"
futures = "0.3.31"
serde = { version = "1.0.219", features = ["derive"] }
sqlx = { version = "0.8.5", features = [
"chrono",
"macros",
"postgres",
"runtime-tokio-rustls",
] }
tokio = { version = "1.45.0", features = ["full"] }
server.path = "server"
post.path = "feature/post"

5
backend/build.rs Normal file
View File

@ -0,0 +1,5 @@
// generated by `sqlx migrate build-script`
fn main() {
// trigger recompilation when a new migration is added
println!("cargo:rerun-if-changed=migrations");
}

View File

@ -8,3 +8,5 @@ actix-web.workspace = true
async-trait.workspace = true
chrono.workspace = true
serde.workspace = true
sqlx.workspace = true
tokio.workspace = true

View File

@ -1,2 +1,2 @@
pub mod controller;
pub mod delivery;
pub mod gateway;

View File

@ -4,7 +4,7 @@ use crate::domain::entity::label::Label;
#[derive(Serialize)]
pub struct LabelResponseDto {
pub id: u32,
pub id: i32,
pub name: String,
pub color: String,
}
@ -14,8 +14,7 @@ impl From<Label> for LabelResponseDto {
Self {
id: entity.id,
name: entity.name,
color: entity.color,
color: format!("#{:08X}", entity.color),
}
}
}

View File

@ -6,12 +6,12 @@ use super::label_response_dto::LabelResponseDto;
#[derive(Serialize)]
pub struct PostInfoResponseDto {
pub id: u32,
pub id: i32,
pub title: String,
pub description: String,
pub preview_image_url: String,
pub labels: Vec<LabelResponseDto>,
pub published_time: i64,
pub published_time: Option<i64>,
}
impl From<PostInfo> for PostInfoResponseDto {
@ -26,7 +26,9 @@ impl From<PostInfo> for PostInfoResponseDto {
.into_iter()
.map(LabelResponseDto::from)
.collect(),
published_time: entity.published_time.timestamp_micros(),
published_time: entity
.published_time
.map(|datetime| datetime.timestamp_micros()),
}
}
}

View File

@ -1 +1,4 @@
pub enum PostError {}
pub enum PostError {
DatabaseError(String),
NotFound,
}

View File

@ -1,5 +1,5 @@
pub struct Label {
pub id: u32,
pub id: i32,
pub name: String,
pub color: String,
pub color: u32,
}

View File

@ -1,7 +1,7 @@
use super::post_info::PostInfo;
pub struct Post {
pub id: u32,
pub id: i32,
pub info: PostInfo,
pub content: String,
}

View File

@ -3,10 +3,10 @@ use chrono::{DateTime, Utc};
use super::label::Label;
pub struct PostInfo {
pub id: u32,
pub id: i32,
pub title: String,
pub description: String,
pub preview_image_url: String,
pub labels: Vec<Label>,
pub published_time: DateTime<Utc>,
pub published_time: Option<DateTime<Utc>>,
}

View File

@ -1,31 +1,84 @@
use std::str::FromStr;
use std::sync::Arc;
use async_trait::async_trait;
use chrono::DateTime;
use chrono::{DateTime, Utc};
use sqlx::{Pool, Postgres};
use crate::{
adapter::gateway::post_db_service::PostDbService, application::error::post_error::PostError,
domain::entity::post_info::PostInfo,
};
pub struct PostDbServiceImpl {}
pub struct PostDbServiceImpl {
db_pool: Arc<Pool<Postgres>>,
}
impl PostDbServiceImpl {
pub fn new() -> Self {
Self {}
pub fn new(db_pool: Arc<Pool<Postgres>>) -> Self {
Self { db_pool }
}
}
#[async_trait]
impl PostDbService for PostDbServiceImpl {
async fn get_all_post_info(&self) -> Result<Vec<PostInfo>, PostError> {
Ok(vec![PostInfo {
id: 1,
title: "Post 1".to_string(),
description: "Description of post 1".to_string(),
preview_image_url: "http://example.com/image1.jpg".to_string(),
labels: vec![],
published_time: DateTime::from_str("2023-10-01T12:00:00Z").unwrap(),
}])
let posts = sqlx::query!(
r#"
SELECT
p.id,
p.title,
p.description,
p.preview_image_url,
p.published_time
FROM post p
WHERE p.deleted_time IS NULL
"#
)
.fetch_all(&*self.db_pool)
.await
.map_err(|err| PostError::DatabaseError(err.to_string()))?;
let mut post_infos = Vec::new();
for post in posts {
let labels = sqlx::query!(
r#"
SELECT
l.id,
l.name,
l.color
FROM label l
JOIN post_label pl ON l.id = pl.label_id
WHERE pl.post_id = $1
AND l.deleted_time IS NULL
"#,
post.id
)
.fetch_all(&*self.db_pool)
.await
.map_err(|err| PostError::DatabaseError(err.to_string()))?;
let domain_labels = labels
.into_iter()
.map(|label| crate::domain::entity::label::Label {
id: label.id,
name: label.name,
color: label.color as u32,
})
.collect();
post_infos.push(PostInfo {
id: post.id,
title: post.title,
description: post.description,
preview_image_url: post.preview_image_url,
labels: domain_labels,
published_time: post
.published_time
.map(|dt| DateTime::<Utc>::from_naive_utc_and_offset(dt, Utc)),
});
}
Ok(post_infos)
}
}

View File

@ -2,13 +2,19 @@ use std::sync::Arc;
use actix_web::{HttpResponse, Responder, web};
use crate::adapter::controller::post_controller::PostController;
use crate::{
adapter::delivery::post_controller::{PostController, PostControllerImpl},
application::use_case::get_all_post_info_use_case::GetAllPostInfoUseCase,
};
pub fn configure_post_routes(cfg: &mut web::ServiceConfig) {
cfg.service(web::resource("/post_info").route(web::get().to(get_all_post_info)));
}
async fn get_all_post_info(post_controller: web::Data<Arc<dyn PostController>>) -> impl Responder {
async fn get_all_post_info(
get_all_post_info_use_case: web::Data<Arc<dyn GetAllPostInfoUseCase>>,
) -> impl Responder {
let post_controller = PostControllerImpl::new(get_all_post_info_use_case.get_ref().clone());
let result = post_controller.get_all_post_info().await;
match result {

View File

@ -0,0 +1,30 @@
-- Add migration script here
CREATE TABLE "post" (
"id" SERIAL PRIMARY KEY NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT NOT NULL,
"preview_image_url" TEXT NOT NULL,
"content" TEXT NOT NULL,
"published_time" TIMESTAMP,
"deleted_time" TIMESTAMP,
"created_time" TIMESTAMP NOT NULL,
"updated_time" TIMESTAMP NOT NULL
);
CREATE TABLE "label" (
"id" SERIAL PRIMARY KEY NOT NULL,
"name" TEXT NOT NULL,
"color" BIGINT NOT NULL CHECK ("color" >= 0 AND "color" <= 4294967295),
"deleted_time" TIMESTAMP,
"created_time" TIMESTAMP NOT NULL,
"updated_time" TIMESTAMP NOT NULL
);
CREATE TABLE "post_label" (
"post_id" INTEGER NOT NULL,
"label_id" INTEGER NOT NULL,
PRIMARY KEY ("post_id", "label_id"),
FOREIGN KEY ("post_id") REFERENCES "post" ("id") ON DELETE CASCADE,
FOREIGN KEY ("label_id") REFERENCES "label" ("id") ON DELETE CASCADE
);

View File

@ -5,6 +5,8 @@ edition.workspace = true
[dependencies]
actix-web.workspace = true
dotenv.workspace = true
env_logger.workspace = true
sqlx.workspace = true
post.workspace = true

View File

@ -1,25 +0,0 @@
use std::sync::Arc;
use post::{
adapter::{
controller::post_controller::{PostController, PostControllerImpl},
gateway::post_repository_impl::PostRepositoryImpl,
},
application::use_case::get_all_post_info_use_case::GetAllPostInfoUseCaseImpl,
framework::db::post_db_service_impl::PostDbServiceImpl,
};
pub struct Controllers {
pub post_controller: Arc<dyn PostController>,
}
impl Controllers {
pub fn new() -> Self {
let post_db_service = Arc::new(PostDbServiceImpl::new());
let post_repository = Arc::new(PostRepositoryImpl::new(post_db_service));
let get_all_post_info_use_case = Arc::new(GetAllPostInfoUseCaseImpl::new(post_repository));
let post_controller = Arc::new(PostControllerImpl::new(get_all_post_info_use_case));
Self { post_controller }
}
}

View File

@ -1 +1 @@
pub mod controllers;
pub mod use_cases;

View File

@ -5,19 +5,44 @@ use actix_web::{
web,
};
use post::framework::web::post_web_routes::configure_post_routes;
use server::controllers::Controllers;
use server::use_cases::UseCases;
use sqlx::{Pool, Postgres, postgres::PgPoolOptions};
use std::{env, sync::Arc};
#[actix_web::main]
async fn main() -> std::io::Result<()> {
dotenv::dotenv().ok();
env_logger::init();
HttpServer::new(create_app)
let db_pool = init_database().await;
HttpServer::new(move || create_app(db_pool.clone()))
.bind(("127.0.0.1", 8080))?
.run()
.await
}
fn create_app() -> App<
async fn init_database() -> Arc<Pool<Postgres>> {
let database_url = env::var("DATABASE_URL")
.unwrap_or_else(|_| "postgres://postgres@localhost:5432/postgres".to_string());
let db_pool = PgPoolOptions::new()
.max_connections(5)
.connect(&database_url)
.await
.expect("Failed to create database connection pool");
sqlx::migrate!("../migrations")
.run(&db_pool)
.await
.expect("Failed to run database migrations");
Arc::new(db_pool)
}
fn create_app(
db_pool: Arc<Pool<Postgres>>,
) -> App<
impl ServiceFactory<
ServiceRequest,
Response = ServiceResponse<impl MessageBody>,
@ -26,9 +51,10 @@ fn create_app() -> App<
Error = Error,
>,
> {
let controllers = Controllers::new();
let use_cases = UseCases::new(db_pool.clone());
App::new()
.app_data(web::Data::new(controllers.post_controller))
.app_data(web::Data::new(db_pool))
.app_data(web::Data::new(use_cases.get_all_post_info_use_case))
.configure(configure_post_routes)
}

View File

@ -0,0 +1,28 @@
use std::sync::Arc;
use post::{
adapter::gateway::post_repository_impl::PostRepositoryImpl,
application::use_case::get_all_post_info_use_case::{
GetAllPostInfoUseCase, GetAllPostInfoUseCaseImpl,
},
framework::db::post_db_service_impl::PostDbServiceImpl,
};
use sqlx::{Pool, Postgres};
pub struct UseCases {
pub get_all_post_info_use_case: Arc<dyn GetAllPostInfoUseCase>,
}
impl UseCases {
pub fn new(db_pool: Arc<Pool<Postgres>>) -> Self {
let post_db_service = Arc::new(PostDbServiceImpl::new(db_pool));
let post_repository = Arc::new(PostRepositoryImpl::new(post_db_service));
let get_all_post_info_use_case = Arc::new(GetAllPostInfoUseCaseImpl::new(post_repository));
Self {
get_all_post_info_use_case,
}
}
}