BLOG-103 Add API documentation with Utoipa #106

Merged
squid merged 4 commits from BLOG-103_backend_api_doc into main 2025-08-02 06:51:37 +08:00
13 changed files with 131 additions and 61 deletions
Showing only changes of commit 562d658ea1 - Show all commits

View File

@ -10,3 +10,4 @@ chrono.workspace = true
log.workspace = true log.workspace = true
serde.workspace = true serde.workspace = true
sqlx.workspace = true sqlx.workspace = true
utoipa.workspace = true

View File

@ -1,8 +1,9 @@
use serde::Serialize; use serde::Serialize;
use utoipa::ToSchema;
use crate::domain::entity::color::Color; use crate::domain::entity::color::Color;
#[derive(Serialize)] #[derive(Serialize, ToSchema)]
pub struct ColorResponseDto { pub struct ColorResponseDto {
pub red: u8, pub red: u8,
pub green: u8, pub green: u8,

View File

@ -1,10 +1,11 @@
use serde::Serialize; use serde::Serialize;
use utoipa::ToSchema;
use crate::{ use crate::{
adapter::delivery::color_response_dto::ColorResponseDto, domain::entity::label::Label, adapter::delivery::color_response_dto::ColorResponseDto, domain::entity::label::Label,
}; };
#[derive(Serialize)] #[derive(Serialize, ToSchema)]
pub struct LabelResponseDto { pub struct LabelResponseDto {
pub id: i32, pub id: i32,
pub name: String, pub name: String,

View File

@ -2,11 +2,14 @@ use std::sync::Arc;
use async_trait::async_trait; use async_trait::async_trait;
use crate::application::{ use crate::{
error::post_error::PostError, adapter::delivery::post_info_query_dto::PostQueryDto,
use_case::{ application::{
get_all_post_info_use_case::GetAllPostInfoUseCase, error::post_error::PostError,
get_full_post_use_case::GetFullPostUseCase, use_case::{
get_all_post_info_use_case::GetAllPostInfoUseCase,
get_full_post_use_case::GetFullPostUseCase,
},
}, },
}; };
@ -16,10 +19,10 @@ use super::{post_info_response_dto::PostInfoResponseDto, post_response_dto::Post
pub trait PostController: Send + Sync { pub trait PostController: Send + Sync {
async fn get_all_post_info( async fn get_all_post_info(
&self, &self,
is_published_only: bool, query: PostQueryDto,
) -> Result<Vec<PostInfoResponseDto>, PostError>; ) -> Result<Vec<PostInfoResponseDto>, PostError>;
async fn get_full_post(&self, id: i32) -> Result<PostResponseDto, PostError>; async fn get_post_by_id(&self, id: i32) -> Result<PostResponseDto, PostError>;
} }
pub struct PostControllerImpl { pub struct PostControllerImpl {
@ -43,9 +46,12 @@ impl PostControllerImpl {
impl PostController for PostControllerImpl { impl PostController for PostControllerImpl {
async fn get_all_post_info( async fn get_all_post_info(
&self, &self,
is_published_only: bool, query: PostQueryDto,
) -> Result<Vec<PostInfoResponseDto>, PostError> { ) -> Result<Vec<PostInfoResponseDto>, PostError> {
let result = self.get_all_post_info_use_case.execute(is_published_only).await; let result = self
.get_all_post_info_use_case
.execute(query.is_published_only.unwrap_or(true))
.await;
result.map(|post_info_list| { result.map(|post_info_list| {
let post_info_response_dto_list: Vec<PostInfoResponseDto> = post_info_list let post_info_response_dto_list: Vec<PostInfoResponseDto> = post_info_list
@ -57,7 +63,7 @@ impl PostController for PostControllerImpl {
}) })
} }
async fn get_full_post(&self, id: i32) -> Result<PostResponseDto, PostError> { async fn get_post_by_id(&self, id: i32) -> Result<PostResponseDto, PostError> {
let result = self.get_full_post_use_case.execute(id).await; let result = self.get_full_post_use_case.execute(id).await;
result.map(PostResponseDto::from) result.map(PostResponseDto::from)

View File

@ -1,6 +1,7 @@
use serde::Deserialize; use serde::Deserialize;
use utoipa::IntoParams;
#[derive(Deserialize)] #[derive(Deserialize, IntoParams)]
pub struct PostQueryDto { pub struct PostQueryDto {
pub is_published_only: Option<bool>, pub is_published_only: Option<bool>,
} }

View File

@ -1,10 +1,11 @@
use serde::Serialize; use serde::Serialize;
use utoipa::ToSchema;
use crate::domain::entity::post_info::PostInfo; use crate::domain::entity::post_info::PostInfo;
use super::label_response_dto::LabelResponseDto; use super::label_response_dto::LabelResponseDto;
#[derive(Serialize)] #[derive(Serialize, ToSchema)]
pub struct PostInfoResponseDto { pub struct PostInfoResponseDto {
pub id: i32, pub id: i32,
pub title: String, pub title: String,

View File

@ -1,10 +1,11 @@
use serde::Serialize; use serde::Serialize;
use utoipa::ToSchema;
use crate::domain::entity::post::Post; use crate::domain::entity::post::Post;
use super::post_info_response_dto::PostInfoResponseDto; use super::post_info_response_dto::PostInfoResponseDto;
#[derive(Serialize)] #[derive(Serialize, ToSchema)]
pub struct PostResponseDto { pub struct PostResponseDto {
pub id: i32, pub id: i32,
pub info: PostInfoResponseDto, pub info: PostInfoResponseDto,

View File

@ -1 +1,5 @@
pub mod post_api_doc;
pub mod post_web_routes; pub mod post_web_routes;
mod get_all_post_info_handler;
mod get_post_by_id_handler;

View File

@ -0,0 +1,33 @@
use actix_web::{HttpResponse, Responder, web};
use crate::adapter::delivery::{
post_controller::PostController, post_info_query_dto::PostQueryDto,
post_info_response_dto::PostInfoResponseDto,
};
#[utoipa::path(
get,
path = "/post/all",
tag = "post",
summary = "Get all post information",
params(
PostQueryDto
),
responses (
(status = 200, body = [PostInfoResponseDto])
)
)]
pub async fn get_all_post_info_handler(
post_controller: web::Data<dyn PostController>,
query: web::Query<PostQueryDto>,
) -> impl Responder {
let result = post_controller.get_all_post_info(query.into_inner()).await;
match result {
Ok(post_info_list) => HttpResponse::Ok().json(post_info_list),
Err(e) => {
log::error!("{e:?}");
HttpResponse::InternalServerError().finish()
}
}
}

View File

@ -0,0 +1,36 @@
use actix_web::{HttpResponse, Responder, web};
use crate::{
adapter::delivery::{post_controller::PostController, post_response_dto::PostResponseDto},
application::error::post_error::PostError,
};
#[utoipa::path(
get,
path = "/post/{id}",
tag = "post",
summary = "Get post by ID",
responses (
(status = 200, body = PostResponseDto),
(status = 404, description = "Post not found")
)
)]
pub async fn get_post_by_id_handler(
post_controller: web::Data<dyn PostController>,
path: web::Path<i32>,
) -> impl Responder {
let id = path.into_inner();
let result = post_controller.get_post_by_id(id).await;
match result {
Ok(post) => HttpResponse::Ok().json(post),
Err(e) => {
if e == PostError::NotFound {
HttpResponse::NotFound().finish()
} else {
log::error!("{e:?}");
HttpResponse::InternalServerError().finish()
}
}
}
}

View File

@ -0,0 +1,16 @@
use utoipa::{OpenApi, openapi};
use crate::framework::web::{
get_all_post_info_handler,
get_post_by_id_handler
};
#[derive(OpenApi)]
#[openapi(paths(
get_all_post_info_handler::get_all_post_info_handler,
get_post_by_id_handler::get_post_by_id_handler
))]
struct PostApiDoc;
pub fn openapi() -> openapi::OpenApi {
PostApiDoc::openapi()
}

View File

@ -1,50 +1,14 @@
use actix_web::{HttpResponse, Responder, web}; use actix_web::web;
use crate::{ use crate::framework::web::{
adapter::delivery::{post_controller::PostController, post_info_query_dto::PostQueryDto}, get_all_post_info_handler::get_all_post_info_handler,
application::error::post_error::PostError, get_post_by_id_handler::get_post_by_id_handler,
}; };
pub fn configure_post_routes(cfg: &mut web::ServiceConfig) { pub fn configure_post_routes(cfg: &mut web::ServiceConfig) {
cfg.service( cfg.service(
web::scope("/post") web::scope("/post")
.route("/all", web::get().to(get_all_post_info_handler)) .route("/all", web::get().to(get_all_post_info_handler))
.route("/{id}", web::get().to(get_full_post_handler)), .route("/{id}", web::get().to(get_post_by_id_handler)),
); );
} }
async fn get_all_post_info_handler(
post_controller: web::Data<dyn PostController>,
query: web::Query<PostQueryDto>,
) -> impl Responder {
let is_published_only = query.is_published_only.unwrap_or_else(|| true);
let result = post_controller.get_all_post_info(is_published_only).await;
match result {
Ok(post_info_list) => HttpResponse::Ok().json(post_info_list),
Err(e) => {
log::error!("{e:?}");
HttpResponse::InternalServerError().finish()
}
}
}
async fn get_full_post_handler(
post_controller: web::Data<dyn PostController>,
path: web::Path<i32>,
) -> impl Responder {
let id = path.into_inner();
let result = post_controller.get_full_post(id).await;
match result {
Ok(post) => HttpResponse::Ok().json(post),
Err(e) => {
if e == PostError::NotFound {
HttpResponse::NotFound().finish()
} else {
log::error!("{e:?}");
HttpResponse::InternalServerError().finish()
}
}
}
}

View File

@ -1,14 +1,19 @@
use actix_web::web; use actix_web::web;
use post::framework::web::post_api_doc;
use utoipa::OpenApi; use utoipa::OpenApi;
use utoipa_redoc::{Redoc, Servable}; use utoipa_redoc::{Redoc, Servable};
#[derive(OpenApi)] #[derive(OpenApi)]
#[openapi(info( #[openapi(
title = "SquidSpirit API", info(
version = env!("CARGO_PKG_VERSION") title = "SquidSpirit API",
))] version = env!("CARGO_PKG_VERSION")
)
)]
pub struct ApiDoc; pub struct ApiDoc;
pub fn configure_api_doc_routes(cfg: &mut web::ServiceConfig) { pub fn configure_api_doc_routes(cfg: &mut web::ServiceConfig) {
cfg.service(Redoc::with_url("/redoc", ApiDoc::openapi())); let openapi = ApiDoc::openapi().merge_from(post_api_doc::openapi());
cfg.service(Redoc::with_url("/redoc", openapi));
} }