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
12 changed files with 201 additions and 109 deletions
Showing only changes of commit 68100e9b54 - Show all commits

1
backend/Cargo.lock generated
View File

@ -429,6 +429,7 @@ dependencies = [
"openidconnect", "openidconnect",
"serde", "serde",
"sqlx", "sqlx",
"utoipa",
] ]
[[package]] [[package]]

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

@ -1,4 +1,5 @@
use actix_web::web; use actix_web::web;
use auth::framework::web::auth_api_doc;
use image::framework::web::image_api_doc; use image::framework::web::image_api_doc;
use post::framework::web::post_api_doc; use post::framework::web::post_api_doc;
use utoipa::{ use utoipa::{
@ -37,8 +38,9 @@ impl utoipa::OpenApi for ApiDoc {
pub fn configure_api_doc_routes(cfg: &mut web::ServiceConfig) { pub fn configure_api_doc_routes(cfg: &mut web::ServiceConfig) {
let openapi = ApiDoc::openapi() let openapi = ApiDoc::openapi()
.merge_from(post_api_doc::openapi()) .merge_from(auth_api_doc::openapi())
.merge_from(image_api_doc::openapi()); .merge_from(image_api_doc::openapi())
.merge_from(post_api_doc::openapi());
cfg.service(Redoc::with_url("/redoc", openapi)); cfg.service(Redoc::with_url("/redoc", openapi));
} }