BLOG-103 Add API documentation with Utoipa (#106)
All checks were successful
Frontend CI / build (push) Successful in 1m8s

### Description

This PR integrates the **`utoipa`** and **`utoipa-redoc`** crates to automatically generate OpenAPI-compliant API documentation for the backend project.

#### Overview

To improve development efficiency and API maintainability, this change introduces `utoipa` to automate the API documentation process. By adding specific attribute macros to the source code, we can generate detailed API specifications directly and serve them through an interactive UI provided by `utoipa-redoc`.

#### Key Changes

* **Dependencies Added**
    * Added `utoipa`, `utoipa-gen`, and `utoipa-redoc` to `Cargo.toml`.
    * `utoipa` is used to define OpenAPI objects.
    * `utoipa-redoc` is used to serve the ReDoc documentation UI.

* **Code Refactoring**
    * **HTTP handler logic** in each feature (`auth`, `image`, `post`) has been extracted from the `..._web_routes.rs` files into their own dedicated files (e.g., `get_post_by_id_handler.rs`). This makes the code structure cleaner and simplifies adding documentation attributes to each handler.
    * Renamed the `PostController` method from `get_full_post` to `get_post_by_id` for a more RESTful-compliant naming convention.

* **API Doc Annotation**
    * Added `#[derive(ToSchema)]` or `#[derive(IntoParams)]` to all DTOs (Data Transfer Objects) so they can be recognized by `utoipa` to generate the corresponding schemas.
    * Added the `#[utoipa::path]` macro to all HTTP handler functions, describing the API's path, HTTP method, tags, summary, expected responses, and security settings.

* **Doc Aggregation & Serving**
    * Added an `..._api_doc.rs` file in each feature module to aggregate all API paths within that module.
    * Added a new `api_doc.rs` file in the `server` crate to merge the OpenAPI documents from all features, set global information (like title, version, and the OAuth2 security scheme), and serve the documentation page on the `/redoc` route using `Redoc::with_url`.

### Package Changes

```toml
utoipa = { version = "5.4.0", features = ["actix_extras"] }
utoipa-redoc = { version = "6.0.0", features = ["actix-web"] }
```

### Screenshots

![image.png](/attachments/f5b4b268-f550-4d9e-9321-49a00f6b8e1a)

### Reference

Resolves #103

### Checklist

- [x] A milestone is set
- [x] The related issuse has been linked to this branch

Reviewed-on: #106
Co-authored-by: SquidSpirit <squid@squidspirit.com>
Co-committed-by: SquidSpirit <squid@squidspirit.com>
This commit is contained in:
SquidSpirit 2025-08-02 06:51:37 +08:00 committed by squid
parent f986810540
commit e255e076dc
36 changed files with 612 additions and 247 deletions

View File

@ -0,0 +1,46 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT id, issuer, source_id, displayed_name, email\n FROM \"user\"\n WHERE id = $1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int4"
},
{
"ordinal": 1,
"name": "issuer",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "source_id",
"type_info": "Varchar"
},
{
"ordinal": 3,
"name": "displayed_name",
"type_info": "Varchar"
},
{
"ordinal": 4,
"name": "email",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Int4"
]
},
"nullable": [
false,
false,
false,
false,
false
]
},
"hash": "9d1ffa7a71c8830d75eeeb26800ee7a7d8ede2410b423985caffd86361ad9263"
}

41
backend/Cargo.lock generated
View File

@ -429,6 +429,7 @@ dependencies = [
"openidconnect", "openidconnect",
"serde", "serde",
"sqlx", "sqlx",
"utoipa",
] ]
[[package]] [[package]]
@ -1669,6 +1670,7 @@ dependencies = [
"log", "log",
"serde", "serde",
"sqlx", "sqlx",
"utoipa",
] ]
[[package]] [[package]]
@ -2234,6 +2236,7 @@ dependencies = [
"log", "log",
"serde", "serde",
"sqlx", "sqlx",
"utoipa",
] ]
[[package]] [[package]]
@ -2819,6 +2822,8 @@ dependencies = [
"percent-encoding", "percent-encoding",
"post", "post",
"sqlx", "sqlx",
"utoipa",
"utoipa-redoc",
] ]
[[package]] [[package]]
@ -3509,6 +3514,42 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "utoipa"
version = "5.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2fcc29c80c21c31608227e0912b2d7fddba57ad76b606890627ba8ee7964e993"
dependencies = [
"indexmap 2.9.0",
"serde",
"serde_json",
"utoipa-gen",
]
[[package]]
name = "utoipa-gen"
version = "5.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d79d08d92ab8af4c5e8a6da20c47ae3f61a0f1dabc1997cdf2d082b757ca08b"
dependencies = [
"proc-macro2",
"quote",
"regex",
"syn",
]
[[package]]
name = "utoipa-redoc"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6427547f6db7ec006cbbef95f7565952a16f362e298b416d2d497d9706fef72d"
dependencies = [
"actix-web",
"serde",
"serde_json",
"utoipa",
]
[[package]] [[package]]
name = "vcpkg" name = "vcpkg"
version = "0.2.15" version = "0.2.15"

View File

@ -30,6 +30,8 @@ sqlx = { version = "0.8.5", features = [
"runtime-tokio-rustls", "runtime-tokio-rustls",
] } ] }
tokio = { version = "1.45.0", features = ["full"] } tokio = { version = "1.45.0", features = ["full"] }
utoipa = { version = "5.4.0", features = ["actix_extras"] }
utoipa-redoc = { version = "6.0.0", features = ["actix-web"] }
server.path = "server" server.path = "server"
auth.path = "feature/auth" auth.path = "feature/auth"

View File

@ -11,3 +11,4 @@ log.workspace = true
openidconnect.workspace = true openidconnect.workspace = true
serde.workspace = true serde.workspace = true
sqlx.workspace = true sqlx.workspace = true
utoipa.workspace = true

View File

@ -1,6 +1,7 @@
use serde::Deserialize; use serde::Deserialize;
use utoipa::IntoParams;
#[derive(Deserialize)] #[derive(Deserialize, IntoParams)]
pub struct OidcCallbackQueryDto { pub struct OidcCallbackQueryDto {
pub code: String, pub code: String,
pub state: String, pub state: String,

View File

@ -1,8 +1,9 @@
use serde::Serialize; use serde::Serialize;
use utoipa::ToSchema;
use crate::domain::entity::user::User; use crate::domain::entity::user::User;
#[derive(Serialize)] #[derive(Serialize, ToSchema)]
pub struct UserResponseDto { pub struct UserResponseDto {
pub id: i32, pub id: i32,
pub displayed_name: String, pub displayed_name: String,

View File

@ -1,4 +1,9 @@
pub mod auth_api_doc;
pub mod auth_middleware; pub mod auth_middleware;
pub mod auth_web_routes; pub mod auth_web_routes;
pub mod get_logged_in_user_handler;
pub mod oidc_callback_handler;
pub mod oidc_login_handler;
pub mod oidc_logout_handler;
mod constants; mod constants;

View File

@ -0,0 +1,17 @@
use crate::framework::web::{
get_logged_in_user_handler, oidc_callback_handler, oidc_login_handler, oidc_logout_handler,
};
use utoipa::{OpenApi, openapi};
#[derive(OpenApi)]
#[openapi(paths(
get_logged_in_user_handler::get_logged_in_user_handler,
oidc_callback_handler::oidc_callback_handler,
oidc_login_handler::oidc_login_handler,
oidc_logout_handler::oidc_logout_handler
))]
struct ApiDoc;
pub fn openapi() -> openapi::OpenApi {
ApiDoc::openapi()
}

View File

@ -1,15 +1,9 @@
use actix_session::Session; use actix_web::web;
use actix_web::{HttpResponse, Responder, http::header, web};
use crate::{ use crate::framework::web::{
adapter::delivery::{ get_logged_in_user_handler::get_logged_in_user_handler,
auth_controller::AuthController, oidc_callback_query_dto::OidcCallbackQueryDto, oidc_callback_handler::oidc_callback_handler, oidc_login_handler::oidc_login_handler,
}, oidc_logout_handler::oidc_logout_handler,
application::error::auth_error::AuthError,
framework::web::{
auth_middleware::UserId,
constants::{SESSION_KEY_AUTH_NONCE, SESSION_KEY_AUTH_STATE, SESSION_KEY_USER_ID},
},
}; };
pub fn configure_auth_routes(cfg: &mut web::ServiceConfig) { pub fn configure_auth_routes(cfg: &mut web::ServiceConfig) {
@ -17,101 +11,8 @@ pub fn configure_auth_routes(cfg: &mut web::ServiceConfig) {
web::scope("/auth") web::scope("/auth")
.route("/login", web::get().to(oidc_login_handler)) .route("/login", web::get().to(oidc_login_handler))
.route("/callback", web::get().to(oidc_callback_handler)) .route("/callback", web::get().to(oidc_callback_handler))
.route("/logout", web::get().to(logout_handler)), .route("/logout", web::get().to(oidc_logout_handler)),
); );
cfg.service(web::resource("/me").route(web::get().to(get_logged_in_user_handler))); cfg.service(web::resource("/me").route(web::get().to(get_logged_in_user_handler)));
} }
async fn oidc_login_handler(
auth_controller: web::Data<dyn AuthController>,
session: Session,
) -> impl Responder {
let result = auth_controller.oidc_login();
match result {
Ok(auth_url) => {
if let Err(e) = session.insert::<String>(SESSION_KEY_AUTH_STATE, auth_url.state) {
log::error!("{e:?}");
return HttpResponse::InternalServerError().finish();
}
if let Err(e) = session.insert::<String>(SESSION_KEY_AUTH_NONCE, auth_url.nonce) {
log::error!("{e:?}");
return HttpResponse::InternalServerError().finish();
}
HttpResponse::Found()
.append_header((header::LOCATION, auth_url.url))
.finish()
}
Err(e) => {
log::error!("{e:?}");
HttpResponse::InternalServerError().finish()
}
}
}
async fn oidc_callback_handler(
auth_controller: web::Data<dyn AuthController>,
query: web::Query<OidcCallbackQueryDto>,
session: Session,
) -> impl Responder {
let expected_state = match session.get::<String>(SESSION_KEY_AUTH_STATE) {
Ok(Some(state)) => state,
_ => return HttpResponse::BadRequest().finish(),
};
let expected_nonce = match session.get::<String>(SESSION_KEY_AUTH_NONCE) {
Ok(Some(nonce)) => nonce,
_ => return HttpResponse::BadRequest().finish(),
};
let result = auth_controller
.oidc_callback(query.into_inner(), &expected_state, &expected_nonce)
.await;
session.remove(SESSION_KEY_AUTH_STATE);
session.remove(SESSION_KEY_AUTH_NONCE);
match result {
Ok(user) => {
if let Err(e) = session.insert::<i32>(SESSION_KEY_USER_ID, user.id) {
log::error!("{e:?}");
return HttpResponse::InternalServerError().finish();
}
HttpResponse::Found()
.append_header((header::LOCATION, "/"))
.finish()
}
Err(e) => match e {
AuthError::InvalidAuthCode
| AuthError::InvalidIdToken
| AuthError::InvalidNonce
| AuthError::InvalidState => HttpResponse::BadRequest().finish(),
_ => {
log::error!("{e:?}");
HttpResponse::InternalServerError().finish()
}
},
}
}
async fn logout_handler(session: Session) -> impl Responder {
session.clear();
HttpResponse::Found()
.append_header((header::LOCATION, "/"))
.finish()
}
async fn get_logged_in_user_handler(
auth_controller: web::Data<dyn AuthController>,
user_id: UserId,
) -> impl Responder {
let result = auth_controller.get_user(user_id.get()).await;
match result {
Ok(user) => HttpResponse::Ok().json(user),
Err(e) => {
log::error!("{e:?}");
HttpResponse::InternalServerError().finish()
}
}
}

View File

@ -0,0 +1,33 @@
use actix_web::{HttpResponse, Responder, web};
use crate::{
adapter::delivery::{auth_controller::AuthController, user_response_dto::UserResponseDto},
framework::web::auth_middleware::UserId,
};
#[utoipa::path(
get,
path = "/me",
tag = "auth",
summary = "Get logged-in user information",
responses(
(status = 200, body = UserResponseDto),
),
security(
("oauth2" = [])
)
)]
pub async fn get_logged_in_user_handler(
auth_controller: web::Data<dyn AuthController>,
user_id: UserId,
) -> impl Responder {
let result = auth_controller.get_user(user_id.get()).await;
match result {
Ok(user) => HttpResponse::Ok().json(user),
Err(e) => {
log::error!("{e:?}");
HttpResponse::InternalServerError().finish()
}
}
}

View File

@ -0,0 +1,69 @@
use actix_session::Session;
use actix_web::{HttpResponse, Responder, http::header, web};
use crate::{
adapter::delivery::{
auth_controller::AuthController, oidc_callback_query_dto::OidcCallbackQueryDto,
},
application::error::auth_error::AuthError,
framework::web::constants::{
SESSION_KEY_AUTH_NONCE, SESSION_KEY_AUTH_STATE, SESSION_KEY_USER_ID,
},
};
#[utoipa::path(
get,
path = "/auth/callback",
tag = "auth",
summary = "Handle OIDC callback",
params(
OidcCallbackQueryDto
),
responses(
(status = 302, description = "Redirect to home page"),
(status = 400, description = "Invalid state or nonce"),
)
)]
pub async fn oidc_callback_handler(
auth_controller: web::Data<dyn AuthController>,
query: web::Query<OidcCallbackQueryDto>,
session: Session,
) -> impl Responder {
let expected_state = match session.get::<String>(SESSION_KEY_AUTH_STATE) {
Ok(Some(state)) => state,
_ => return HttpResponse::BadRequest().finish(),
};
let expected_nonce = match session.get::<String>(SESSION_KEY_AUTH_NONCE) {
Ok(Some(nonce)) => nonce,
_ => return HttpResponse::BadRequest().finish(),
};
let result = auth_controller
.oidc_callback(query.into_inner(), &expected_state, &expected_nonce)
.await;
session.remove(SESSION_KEY_AUTH_STATE);
session.remove(SESSION_KEY_AUTH_NONCE);
match result {
Ok(user) => {
if let Err(e) = session.insert::<i32>(SESSION_KEY_USER_ID, user.id) {
log::error!("{e:?}");
return HttpResponse::InternalServerError().finish();
}
HttpResponse::Found()
.append_header((header::LOCATION, "/"))
.finish()
}
Err(e) => match e {
AuthError::InvalidAuthCode
| AuthError::InvalidIdToken
| AuthError::InvalidNonce
| AuthError::InvalidState => HttpResponse::BadRequest().finish(),
_ => {
log::error!("{e:?}");
HttpResponse::InternalServerError().finish()
}
},
}
}

View File

@ -0,0 +1,43 @@
use actix_session::Session;
use actix_web::{HttpResponse, Responder, http::header, web};
use crate::{
adapter::delivery::auth_controller::AuthController,
framework::web::constants::{SESSION_KEY_AUTH_NONCE, SESSION_KEY_AUTH_STATE},
};
#[utoipa::path(
get,
path = "/auth/login",
tag = "auth",
summary = "Initiate OIDC login",
responses(
(status = 302, description = "Redirect to OIDC provider")
)
)]
pub async fn oidc_login_handler(
auth_controller: web::Data<dyn AuthController>,
session: Session,
) -> impl Responder {
let result = auth_controller.oidc_login();
match result {
Ok(auth_url) => {
if let Err(e) = session.insert::<String>(SESSION_KEY_AUTH_STATE, auth_url.state) {
log::error!("{e:?}");
return HttpResponse::InternalServerError().finish();
}
if let Err(e) = session.insert::<String>(SESSION_KEY_AUTH_NONCE, auth_url.nonce) {
log::error!("{e:?}");
return HttpResponse::InternalServerError().finish();
}
HttpResponse::Found()
.append_header((header::LOCATION, auth_url.url))
.finish()
}
Err(e) => {
log::error!("{e:?}");
HttpResponse::InternalServerError().finish()
}
}
}

View File

@ -0,0 +1,18 @@
use actix_session::Session;
use actix_web::{HttpResponse, Responder, http::header};
#[utoipa::path(
get,
path = "/auth/logout",
tag = "auth",
summary = "Logout user",
responses(
(status = 302, description = "Redirect to home page")
)
)]
pub async fn oidc_logout_handler(session: Session) -> impl Responder {
session.clear();
HttpResponse::Found()
.append_header((header::LOCATION, "/"))
.finish()
}

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

@ -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,12 +2,15 @@ use std::sync::Arc;
use async_trait::async_trait; use async_trait::async_trait;
use crate::application::{ use crate::{
adapter::delivery::post_info_query_dto::PostQueryDto,
application::{
error::post_error::PostError, error::post_error::PostError,
use_case::{ use_case::{
get_all_post_info_use_case::GetAllPostInfoUseCase, get_all_post_info_use_case::GetAllPostInfoUseCase,
get_full_post_use_case::GetFullPostUseCase, get_full_post_use_case::GetFullPostUseCase,
}, },
},
}; };
use super::{post_info_response_dto::PostInfoResponseDto, post_response_dto::PostResponseDto}; use super::{post_info_response_dto::PostInfoResponseDto, post_response_dto::PostResponseDto};
@ -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,8 @@
use serde::Deserialize; use serde::Deserialize;
use utoipa::IntoParams;
#[derive(Deserialize)] #[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,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,13 @@
use crate::framework::web::{get_all_post_info_handler, get_post_by_id_handler};
use utoipa::{OpenApi, openapi};
#[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 ApiDoc;
pub fn openapi() -> openapi::OpenApi {
ApiDoc::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

@ -12,6 +12,8 @@ hex.workspace = true
openidconnect.workspace = true openidconnect.workspace = true
percent-encoding.workspace = true percent-encoding.workspace = true
sqlx.workspace = true sqlx.workspace = true
utoipa.workspace = true
utoipa-redoc.workspace = true
auth.workspace = true auth.workspace = true
image.workspace = true image.workspace = true

View File

@ -0,0 +1,46 @@
use actix_web::web;
use auth::framework::web::auth_api_doc;
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(auth_api_doc::openapi())
.merge_from(image_api_doc::openapi())
.merge_from(post_api_doc::openapi());
cfg.service(Redoc::with_url("/redoc", openapi));
}

View File

@ -1,2 +1,3 @@
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::{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]
@ -68,6 +68,7 @@ fn create_app(
.app_data(web::Data::from(container.auth_controller)) .app_data(web::Data::from(container.auth_controller))
.app_data(web::Data::from(container.image_controller)) .app_data(web::Data::from(container.image_controller))
.app_data(web::Data::from(container.post_controller)) .app_data(web::Data::from(container.post_controller))
.configure(configure_api_doc_routes)
.configure(configure_auth_routes) .configure(configure_auth_routes)
.configure(configure_image_routes) .configure(configure_image_routes)
.configure(configure_post_routes) .configure(configure_post_routes)