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
14 changed files with 208 additions and 110 deletions
Showing only changes of commit 1cf634bd19 - Show all commits

1
backend/Cargo.lock generated
View File

@ -1669,6 +1669,7 @@ dependencies = [
"log", "log",
"serde", "serde",
"sqlx", "sqlx",
"utoipa",
] ]
[[package]] [[package]]

View File

@ -11,5 +11,6 @@ futures.workspace = true
log.workspace = true log.workspace = true
serde.workspace = true serde.workspace = true
sqlx.workspace = true sqlx.workspace = true
utoipa.workspace = true
auth.workspace = true auth.workspace = true

View File

@ -1,6 +1,7 @@
use serde::Serialize; use serde::Serialize;
use utoipa::ToSchema;
#[derive(Serialize)] #[derive(Serialize, ToSchema)]
pub struct ImageInfoResponseDto { pub struct ImageInfoResponseDto {
pub id: i32, pub id: i32,
pub mime_type: String, pub mime_type: String,

View File

@ -1 +1,5 @@
pub mod image_api_doc;
pub mod image_web_routes; pub mod image_web_routes;
mod get_image_by_id_handler;
mod upload_image_handler;

View File

@ -0,0 +1,43 @@
use actix_web::{HttpResponse, Responder, web};
use utoipa::ToSchema;
use crate::{
adapter::delivery::image_controller::ImageController,
application::error::image_error::ImageError,
};
#[utoipa::path(
get,
path = "/image/{id}",
tag = "image",
summary = "Get image by ID",
responses (
(status = 200, body = inline(ResponseBodySchema), content_type = "image/*"),
(status = 404, description = "Image not found")
)
)]
pub async fn get_image_by_id_handler(
image_controller: web::Data<dyn ImageController>,
path: web::Path<i32>,
) -> impl Responder {
let id = path.into_inner();
let result = image_controller.get_image_by_id(id).await;
match result {
Ok(image_response) => HttpResponse::Ok()
.content_type(image_response.mime_type)
.body(image_response.data),
Err(e) => match e {
ImageError::NotFound => HttpResponse::NotFound().finish(),
_ => {
log::error!("{e:?}");
HttpResponse::InternalServerError().finish()
}
},
}
}
#[derive(ToSchema)]
#[schema(value_type = String, format = Binary)]
#[allow(dead_code)]
struct ResponseBodySchema(Vec<u8>);

View File

@ -0,0 +1,13 @@
use crate::framework::web::{get_image_by_id_handler, upload_image_handler};
use utoipa::{OpenApi, openapi};
#[derive(OpenApi)]
#[openapi(paths(
get_image_by_id_handler::get_image_by_id_handler,
upload_image_handler::upload_image_handler
))]
struct ApiDoc;
pub fn openapi() -> openapi::OpenApi {
ApiDoc::openapi()
}

View File

@ -1,11 +1,7 @@
use actix_multipart::Multipart; use actix_web::web;
use actix_web::{HttpResponse, Responder, web};
use auth::framework::web::auth_middleware::UserId;
use futures::StreamExt;
use crate::{ use crate::framework::web::{
adapter::delivery::{image_controller::ImageController, image_request_dto::ImageRequestDto}, get_image_by_id_handler::get_image_by_id_handler, upload_image_handler::upload_image_handler,
application::error::image_error::ImageError,
}; };
pub fn configure_image_routes(cfg: &mut web::ServiceConfig) { pub fn configure_image_routes(cfg: &mut web::ServiceConfig) {
@ -15,77 +11,3 @@ pub fn configure_image_routes(cfg: &mut web::ServiceConfig) {
.route("/{id}", web::get().to(get_image_by_id_handler)), .route("/{id}", web::get().to(get_image_by_id_handler)),
); );
} }
async fn upload_image_handler(
image_controller: web::Data<dyn ImageController>,
mut payload: Multipart,
_: UserId,
) -> impl Responder {
let mut image_request_dto: Option<ImageRequestDto> = None;
while let Some(item) = payload.next().await {
let mut field = match item {
Ok(field) => field,
Err(_) => return HttpResponse::BadRequest().finish(),
};
if field.name() != Some("file") {
continue;
}
let mime_type = field
.content_type()
.cloned()
.map(|mt| mt.to_string())
.unwrap_or_else(|| "application/octet-stream".to_string());
let mut data = Vec::new();
while let Some(chunk) = field.next().await {
match chunk {
Ok(bytes) => data.extend_from_slice(&bytes),
Err(_) => return HttpResponse::InternalServerError().finish(),
}
}
image_request_dto = Some(ImageRequestDto { mime_type, data });
break;
}
let image_request_dto = match image_request_dto {
Some(dto) => dto,
None => return HttpResponse::BadRequest().finish(),
};
let result = image_controller.upload_image(image_request_dto).await;
match result {
Ok(image_info) => HttpResponse::Created().json(image_info),
Err(e) => match e {
ImageError::UnsupportedMimeType => HttpResponse::BadRequest().body(format!("{e:?}")),
_ => {
log::error!("{e:?}");
HttpResponse::InternalServerError().finish()
}
},
}
}
async fn get_image_by_id_handler(
image_controller: web::Data<dyn ImageController>,
path: web::Path<i32>,
) -> impl Responder {
let id = path.into_inner();
let result = image_controller.get_image_by_id(id).await;
match result {
Ok(image_response) => HttpResponse::Ok()
.content_type(image_response.mime_type)
.body(image_response.data),
Err(e) => match e {
ImageError::NotFound => HttpResponse::NotFound().finish(),
_ => {
log::error!("{e:?}");
HttpResponse::InternalServerError().finish()
}
},
}
}

View File

@ -0,0 +1,90 @@
use actix_multipart::Multipart;
use actix_web::{HttpResponse, Responder, web};
use auth::framework::web::auth_middleware::UserId;
use futures::StreamExt;
use utoipa::ToSchema;
use crate::{
adapter::delivery::{
image_controller::ImageController, image_info_response_dto::ImageInfoResponseDto,
image_request_dto::ImageRequestDto,
},
application::error::image_error::ImageError,
};
#[utoipa::path(
post,
path = "/image/upload",
tag = "image",
summary = "Upload an image",
request_body (
content = RequestBodySchema,
content_type = "multipart/form-data",
),
responses (
(status = 201, body = ImageInfoResponseDto),
(status = 400, description = "Unsupported MIME type or file field not found"),
),
security(
("oauth2" = [])
)
)]
pub async fn upload_image_handler(
image_controller: web::Data<dyn ImageController>,
mut payload: Multipart,
_: UserId,
) -> impl Responder {
let mut image_request_dto: Option<ImageRequestDto> = None;
while let Some(item) = payload.next().await {
let mut field = match item {
Ok(field) => field,
Err(_) => return HttpResponse::BadRequest().finish(),
};
if field.name() != Some("file") {
continue;
}
let mime_type = field
.content_type()
.cloned()
.map(|mt| mt.to_string())
.unwrap_or_else(|| "application/octet-stream".to_string());
let mut data = Vec::new();
while let Some(chunk) = field.next().await {
match chunk {
Ok(bytes) => data.extend_from_slice(&bytes),
Err(_) => return HttpResponse::InternalServerError().finish(),
}
}
image_request_dto = Some(ImageRequestDto { mime_type, data });
break;
}
let image_request_dto = match image_request_dto {
Some(dto) => dto,
None => return HttpResponse::BadRequest().finish(),
};
let result = image_controller.upload_image(image_request_dto).await;
match result {
Ok(image_info) => HttpResponse::Created().json(image_info),
Err(e) => match e {
ImageError::UnsupportedMimeType => HttpResponse::BadRequest().body(format!("{e:?}")),
_ => {
log::error!("{e:?}");
HttpResponse::InternalServerError().finish()
}
},
}
}
#[derive(ToSchema)]
#[allow(dead_code)]
struct RequestBodySchema {
#[schema(value_type = String, format = Binary)]
file: Vec<u8>,
}

View File

@ -3,5 +3,6 @@ use utoipa::IntoParams;
#[derive(Deserialize, IntoParams)] #[derive(Deserialize, IntoParams)]
pub struct PostQueryDto { pub struct PostQueryDto {
#[param(default = true)]
pub is_published_only: Option<bool>, pub is_published_only: Option<bool>,
} }

View File

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

View File

@ -0,0 +1,44 @@
use actix_web::web;
use image::framework::web::image_api_doc;
use post::framework::web::post_api_doc;
use utoipa::{
OpenApi,
openapi::{
Components, InfoBuilder, OpenApiBuilder,
security::{AuthorizationCode, Flow, OAuth2, Scopes, SecurityScheme},
},
};
use utoipa_redoc::{Redoc, Servable};
pub struct ApiDoc;
impl utoipa::OpenApi for ApiDoc {
fn openapi() -> utoipa::openapi::OpenApi {
let mut components = Components::new();
components.add_security_scheme(
"oauth2",
SecurityScheme::OAuth2(OAuth2::new(vec![Flow::AuthorizationCode(
AuthorizationCode::new("/auth/login", "/auth/callback", Scopes::new()),
)])),
);
OpenApiBuilder::new()
.info(
InfoBuilder::new()
.title("SquidSpirit API")
.version(env!("CARGO_PKG_VERSION"))
.build(),
)
.components(Some(components))
.build()
}
}
pub fn configure_api_doc_routes(cfg: &mut web::ServiceConfig) {
let openapi = ApiDoc::openapi()
.merge_from(post_api_doc::openapi())
.merge_from(image_api_doc::openapi());
cfg.service(Redoc::with_url("/redoc", openapi));
}

View File

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

View File

@ -1,3 +1,3 @@
pub mod apidoc; pub mod api_doc;
pub mod configuration; pub mod configuration;
pub mod container; pub mod container;

View File

@ -11,7 +11,7 @@ use auth::framework::web::auth_web_routes::configure_auth_routes;
use image::framework::web::image_web_routes::configure_image_routes; use image::framework::web::image_web_routes::configure_image_routes;
use openidconnect::reqwest; use openidconnect::reqwest;
use post::framework::web::post_web_routes::configure_post_routes; use post::framework::web::post_web_routes::configure_post_routes;
use server::{apidoc::configure_api_doc_routes, configuration::Configuration, container::Container}; use server::{api_doc::configure_api_doc_routes, configuration::Configuration, container::Container};
use sqlx::{Pool, Postgres}; use sqlx::{Pool, Postgres};
#[actix_web::main] #[actix_web::main]