From 8e12c79ea6aa4c6b1a8edf309c9841305bffcca6 Mon Sep 17 00:00:00 2001 From: Maulana Sodiqin Date: Tue, 25 Mar 2025 20:50:42 +0700 Subject: [PATCH] feat: permissions controller --- src/apps/mod.rs | 3 + src/apps/v1/docs/docs_controller.rs | 24 ++- src/apps/v1/mod.rs | 1 + src/apps/v1/permissions/mod.rs | 18 ++ .../v1/permissions/permissions_controller.rs | 102 +++++++++++ .../permissions_controller_test.rs | 171 ++++++++++++++++++ src/apps/v1/permissions/permissions_dto.rs | 4 +- .../v1/permissions/permissions_repository.rs | 18 ++ .../v1/permissions/permissions_service.rs | 112 ++++++++++++ 9 files changed, 443 insertions(+), 10 deletions(-) create mode 100644 src/apps/v1/permissions/permissions_controller.rs create mode 100644 src/apps/v1/permissions/permissions_controller_test.rs create mode 100644 src/apps/v1/permissions/permissions_service.rs diff --git a/src/apps/mod.rs b/src/apps/mod.rs index 9d57e72..2e45d3b 100644 --- a/src/apps/mod.rs +++ b/src/apps/mod.rs @@ -1,6 +1,8 @@ use crate::{AppState, Env, SurrealMemClient, SurrealWsClient}; use axum::{ http::{header, HeaderValue, Method}, + response::Redirect, + routing::get, Extension, Router, }; use tower_http::cors::CorsLayer; @@ -44,6 +46,7 @@ pub async fn apps( .allow_credentials(true); Router::new() + .route("/", get(Redirect::to("/docs"))) .nest("/v1", v1::routes().await) .nest("/v2", v2::routes().await) .merge(SwaggerUi::new("/docs").url("/openapi.json", v1::docs_router())) diff --git a/src/apps/v1/docs/docs_controller.rs b/src/apps/v1/docs/docs_controller.rs index f4f2899..30a0589 100644 --- a/src/apps/v1/docs/docs_controller.rs +++ b/src/apps/v1/docs/docs_controller.rs @@ -1,12 +1,7 @@ use crate::{ v1::{ - auth, users, roles, AuthLoginRequestDto, AuthLoginResponsetDto, - AuthResendOtpRequestDto, AuthVerifyEmailRequestDto, - }, - AuthNewPasswordRequestDto, AuthRefreshTokenRequestDto, MessageResponseDto, - MetaRequestDto, MetaResponseDto, ResponseListSuccessDto, ResponseSuccessDto, - RolesItemDto, RolesRequestCreateDto, RolesRequestUpdateDto, TokenDto, - UsersItemDto, + auth, permissions, roles, users, AuthLoginRequestDto, AuthLoginResponsetDto, AuthResendOtpRequestDto, AuthVerifyEmailRequestDto + }, AuthNewPasswordRequestDto, AuthRefreshTokenRequestDto, MessageResponseDto, MetaRequestDto, MetaResponseDto, PermissionsItemDto, PermissionsRequestDto, ResponseListSuccessDto, ResponseSuccessDto, RolesItemDto, RolesRequestCreateDto, RolesRequestUpdateDto, TokenDto, UsersItemDto }; use utoipa::{ @@ -34,7 +29,12 @@ use utoipa::{ roles::roles_controller::get_role_by_id, roles::roles_controller::post_create_role, roles::roles_controller::put_update_role, - roles::roles_controller::delete_role + roles::roles_controller::delete_role, + permissions::permissions_controller::get_permission_list, + permissions::permissions_controller::get_permission_by_id, + permissions::permissions_controller::post_create_permission, + permissions::permissions_controller::put_update_permission, + permissions::permissions_controller::delete_permission ), components( schemas( @@ -51,9 +51,15 @@ use utoipa::{ RolesItemDto, RolesRequestCreateDto, RolesRequestUpdateDto, + PermissionsRequestDto, + PermissionsItemDto, ResponseSuccessDto, ResponseListSuccessDto>, - ResponseListSuccessDto> + ResponseSuccessDto, + ResponseListSuccessDto>, + ResponseSuccessDto, + ResponseListSuccessDto>, + ResponseSuccessDto ) ), info( diff --git a/src/apps/v1/mod.rs b/src/apps/v1/mod.rs index 887a74c..b767c94 100644 --- a/src/apps/v1/mod.rs +++ b/src/apps/v1/mod.rs @@ -16,6 +16,7 @@ pub async fn routes() -> Router { let protected_routes = Router::new() .nest("/users", users_router()) .nest("/roles", roles_router()) + .nest("/permissions", permissions_router()) .layer(from_fn(auth::auth_middleware::auth_middleware)); Router::new().merge(public_routes).merge(protected_routes) } diff --git a/src/apps/v1/permissions/mod.rs b/src/apps/v1/permissions/mod.rs index 85b6ac2..8a6ff77 100644 --- a/src/apps/v1/permissions/mod.rs +++ b/src/apps/v1/permissions/mod.rs @@ -1,12 +1,30 @@ +use axum::{ + routing::{delete, get, post, put}, + Router, +}; +pub mod permissions_controller; pub mod permissions_dto; pub mod permissions_enum; pub mod permissions_repository; pub mod permissions_schema; +pub mod permissions_service; +#[cfg(test)] +pub mod permissions_controller_test; #[cfg(test)] pub mod permissions_repository_test; +pub use permissions_controller::*; pub use permissions_dto::*; pub use permissions_enum::*; pub use permissions_repository::*; pub use permissions_schema::*; + +pub fn permissions_router() -> Router { + Router::new() + .route("/", get(get_permission_list)) + .route("/create", post(post_create_permission)) + .route("/detail/{id}", get(get_permission_by_id)) + .route("/update/{id}", put(put_update_permission)) + .route("/delete/{id}", delete(delete_permission)) +} diff --git a/src/apps/v1/permissions/permissions_controller.rs b/src/apps/v1/permissions/permissions_controller.rs new file mode 100644 index 0000000..4fb7a36 --- /dev/null +++ b/src/apps/v1/permissions/permissions_controller.rs @@ -0,0 +1,102 @@ +use axum::{ + extract::{Path, Query}, + response::IntoResponse, + Extension, Json, +}; + +use crate::{ + v1::{ + permissions_dto::{PermissionsItemDto, PermissionsRequestDto}, + permissions_service::PermissionsService, + }, + AppState, MessageResponseDto, MetaRequestDto, ResponseListSuccessDto, + ResponseSuccessDto, +}; + +#[utoipa::path( + get, + path = "/v1/permissions", + params( + ("page" = Option, Query, description = "Page number"), + ("per_page" = Option, Query, description = "Items per page"), + ("search" = Option, Query, description = "Search keyword"), + ("sort_by" = Option, Query, description = "Sort by field"), + ("order" = Option, Query, description = "Order ASC or DESC"), + ("filter" = Option, Query, description = "Filter value"), + ("filter_by" = Option, Query, description = "Field to filter by"), + ), + responses( + (status = 200, description = "Get permission list", body = ResponseListSuccessDto>) + ), + tag = "Permissions" +)] +pub async fn get_permission_list( + Extension(state): Extension, + Query(meta): Query, +) -> impl IntoResponse { + PermissionsService::get_permission_list(&state, meta).await +} + +#[utoipa::path( + get, + path = "/v1/permissions/detail/{id}", + params(("id" = String, Path, description = "Permission ID")), + responses( + (status = 200, description = "Get permission by ID", body = ResponseSuccessDto) + ), + tag = "Permissions" +)] +pub async fn get_permission_by_id( + Extension(state): Extension, + Path(id): Path, +) -> impl IntoResponse { + PermissionsService::get_permission_by_id(&state, id).await +} + +#[utoipa::path( + post, + path = "/v1/permissions/create", + request_body = PermissionsRequestDto, + responses( + (status = 201, description = "Create new permission", body = MessageResponseDto) + ), + tag = "Permissions" +)] +pub async fn post_create_permission( + Extension(state): Extension, + Json(payload): Json, +) -> impl IntoResponse { + PermissionsService::create_role(&state, payload).await +} + +#[utoipa::path( + put, + path = "/v1/permissions/update/{id}", + request_body = PermissionsRequestDto, + responses( + (status = 200, description = "Update permission", body = MessageResponseDto) + ), + tag = "Permissions" +)] +pub async fn put_update_permission( + Extension(state): Extension, + Path(id): Path, + Json(payload): Json, +) -> impl IntoResponse { + PermissionsService::update_permission(&state, payload, id).await +} + +#[utoipa::path( + delete, + path = "/v1/permissions/delete/{id}", + responses( + (status = 200, description = "Delete permission", body = MessageResponseDto) + ), + tag = "Permissions" +)] +pub async fn delete_permission( + Extension(state): Extension, + Path(id): Path, +) -> impl IntoResponse { + PermissionsService::delete_permission(&state, id).await +} diff --git a/src/apps/v1/permissions/permissions_controller_test.rs b/src/apps/v1/permissions/permissions_controller_test.rs new file mode 100644 index 0000000..a2b5633 --- /dev/null +++ b/src/apps/v1/permissions/permissions_controller_test.rs @@ -0,0 +1,171 @@ +use crate::{ + create_mock_app_state, permissions_router, + v1::{ + permissions_dto::PermissionsRequestDto, + permissions_repository::PermissionsRepository, + }, + AppState, +}; +use axum::{http::StatusCode, Extension, Router}; +use axum_test::TestServer; + +pub fn create_test_app(state: AppState) -> TestServer { + let app = Router::new() + .nest("/v1/permissions", permissions_router()) + .layer(Extension(state)); + TestServer::new(app).unwrap() +} + +async fn create_dummy_permission( + repo: &PermissionsRepository<'_>, + name: &str, +) -> String { + let dto = crate::v1::permissions_schema::PermissionsSchema { + name: name.into(), + ..Default::default() + }; + let _ = repo.query_create_permission(dto.clone()).await.unwrap(); + let found = repo.query_permission_by_name(name.into()).await.unwrap(); + found.id.id.to_raw() +} + +async fn delete_dummy_permission(repo: &PermissionsRepository<'_>, id: String) { + let _ = repo.query_delete_permission(id).await; +} + +#[tokio::test] +async fn test_create_permission_should_return_201() { + let state = create_mock_app_state().await; + let repo = PermissionsRepository::new(&state); + let server = create_test_app(state.clone()); + let payload = PermissionsRequestDto { + name: "Create Permission".into(), + }; + let res = server.post("/v1/permissions/create").json(&payload).await; + dbg!(res.text()); + assert_eq!(res.status_code(), StatusCode::CREATED); + let id = repo + .query_permission_by_name(payload.name.clone()) + .await + .unwrap() + .id + .id + .to_raw(); + delete_dummy_permission(&repo, id).await; +} + +#[tokio::test] +async fn test_get_permission_list_should_return_200() { + let state = create_mock_app_state().await; + let server = create_test_app(state); + let res = server.get("/v1/permissions?page=1&per_page=10").await; + dbg!(res.text()); + assert_eq!(res.status_code(), StatusCode::OK); +} + +#[tokio::test] +async fn test_get_permission_by_id_should_return_200() { + let state = create_mock_app_state().await; + let repo = PermissionsRepository::new(&state); + let id = create_dummy_permission(&repo, "Get Permission").await; + let server = create_test_app(state.clone()); + let res = server.get(&format!("/v1/permissions/detail/{}", id)).await; + dbg!(res.text()); + assert_eq!(res.status_code(), StatusCode::OK); + delete_dummy_permission(&repo, id).await; +} + +#[tokio::test] +async fn test_update_permission_should_return_200() { + let state = create_mock_app_state().await; + let repo = PermissionsRepository::new(&state); + let id = create_dummy_permission(&repo, "To Update").await; + let server = create_test_app(state.clone()); + let payload = PermissionsRequestDto { + name: "Updated Permission".into(), + }; + let res = server + .put(&format!("/v1/permissions/update/{}", id.clone())) + .json(&payload) + .await; + dbg!(res.text()); + assert_eq!(res.status_code(), StatusCode::OK); + delete_dummy_permission(&repo, id).await; +} + +#[tokio::test] +async fn test_delete_permission_should_return_200() { + let state = create_mock_app_state().await; + let repo = PermissionsRepository::new(&state); + let id = create_dummy_permission(&repo, "To Delete").await; + let server = create_test_app(state); + let res = server + .delete(&format!("/v1/permissions/delete/{}", id)) + .await; + dbg!(res.text()); + assert_eq!(res.status_code(), StatusCode::OK); +} + +#[tokio::test] +async fn test_create_duplicate_permission_should_return_409() { + let state = create_mock_app_state().await; + let repo = PermissionsRepository::new(&state); + let name = "Duplicate Permission"; + let id = create_dummy_permission(&repo, name).await; + let server = create_test_app(state.clone()); + let payload = PermissionsRequestDto { name: name.into() }; + let res = server.post("/v1/permissions/create").json(&payload).await; + assert_eq!(res.status_code(), StatusCode::CONFLICT); + delete_dummy_permission(&repo, id).await; +} + +#[tokio::test] +async fn test_delete_nonexistent_permission_should_return_404() { + let state = create_mock_app_state().await; + let server = create_test_app(state); + let res = server.delete("/v1/permissions/delete/nonexistent-id").await; + assert_eq!(res.status_code(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn test_create_permission_with_empty_name_should_return_400() { + let state = create_mock_app_state().await; + let server = create_test_app(state); + let payload = PermissionsRequestDto { name: "".into() }; + let res = server.post("/v1/permissions/create").json(&payload).await; + dbg!(res.text()); + assert_eq!(res.status_code(), StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn test_update_nonexistent_permission_should_return_404() { + let state = create_mock_app_state().await; + let server = create_test_app(state); + let payload = PermissionsRequestDto { + name: "Nonexistent".into(), + }; + let res = server + .put("/v1/permissions/update/non-exist") + .json(&payload) + .await; + dbg!(res.text()); + assert_eq!(res.status_code(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn test_get_permission_by_invalid_id_should_return_404() { + let state = create_mock_app_state().await; + let server = create_test_app(state); + let res = server.get("/v1/permissions/detail/invalid-id").await; + dbg!(res.text()); + assert_eq!(res.status_code(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn test_get_permission_list_with_invalid_pagination_should_return_400() { + let state = create_mock_app_state().await; + let server = create_test_app(state); + let res = server.get("/v1/permissions?page=abc&per_page=xyz").await; + dbg!(res.text()); + assert_eq!(res.status_code(), StatusCode::BAD_REQUEST); +} diff --git a/src/apps/v1/permissions/permissions_dto.rs b/src/apps/v1/permissions/permissions_dto.rs index 9d4b9c6..9b229c5 100644 --- a/src/apps/v1/permissions/permissions_dto.rs +++ b/src/apps/v1/permissions/permissions_dto.rs @@ -1,8 +1,10 @@ use serde::{Deserialize, Serialize}; use utoipa::ToSchema; +use validator::Validate; -#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)] pub struct PermissionsRequestDto { + #[validate(length(min = 1, message = "Permission name must not be empty"))] pub name: String, } diff --git a/src/apps/v1/permissions/permissions_repository.rs b/src/apps/v1/permissions/permissions_repository.rs index c307e1f..af8e078 100644 --- a/src/apps/v1/permissions/permissions_repository.rs +++ b/src/apps/v1/permissions/permissions_repository.rs @@ -49,6 +49,24 @@ impl<'a> PermissionsRepository<'a> { } } + pub async fn query_permission_by_name( + &self, + name: String, + ) -> Result { + let db = &self.state.surrealdb_ws; + let sql = format!( + "SELECT * FROM {} WHERE name = $name AND is_deleted = false", + ResourceEnum::Permissions.to_string() + ); + let result: Vec = + db.query(sql).bind(("name", name.clone())).await?.take(0)?; + if let Some(permission) = result.into_iter().next() { + Ok(permission.into()) + } else { + bail!("Permission not found") + } + } + pub async fn query_create_permission( &self, data: PermissionsSchema, diff --git a/src/apps/v1/permissions/permissions_service.rs b/src/apps/v1/permissions/permissions_service.rs new file mode 100644 index 0000000..dc77cf8 --- /dev/null +++ b/src/apps/v1/permissions/permissions_service.rs @@ -0,0 +1,112 @@ +use crate::{ + common_response, make_thing, success_list_response, success_response, + validate_request, AppState, MetaRequestDto, PermissionsRepository, + PermissionsSchema, ResourceEnum, ResponseListSuccessDto, ResponseSuccessDto, +}; +use axum::http::StatusCode; +use axum::response::Response; + +use super::PermissionsRequestDto; + +pub struct PermissionsService; + +impl PermissionsService { + pub async fn get_permission_list( + state: &AppState, + meta: MetaRequestDto, + ) -> Response { + let repo = PermissionsRepository::new(state); + match repo.query_permission_list(meta).await { + Ok(data) => { + let response = ResponseListSuccessDto { + data: data.data, + meta: data.meta, + }; + success_list_response(response) + } + Err(e) => common_response(StatusCode::BAD_REQUEST, &e.to_string()), + } + } + + pub async fn get_permission_by_id(state: &AppState, id: String) -> Response { + let repo = PermissionsRepository::new(state); + match repo.query_permission_by_id(id).await { + Ok(permission) => success_response(ResponseSuccessDto { data: permission }), + Err(e) => common_response(StatusCode::NOT_FOUND, &e.to_string()), + } + } + + pub async fn create_role( + state: &AppState, + payload: PermissionsRequestDto, + ) -> Response { + if let Err((status, message)) = validate_request(&payload) { + return common_response(status, &message); + } + let repo = PermissionsRepository::new(state); + match repo.query_permission_by_name(payload.name.clone()).await { + Ok(_role) => { + return common_response( + StatusCode::CONFLICT, + "Permission name already exists", + ); + } + Err(err) if err.to_string().contains("not found") => {} + Err(e) => { + return common_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()); + } + } + match repo + .query_create_permission(PermissionsSchema { + name: payload.name, + ..Default::default() + }) + .await + { + Ok(msg) => common_response(StatusCode::CREATED, &msg), + Err(e) => common_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), + } + } + + pub async fn update_permission( + state: &AppState, + payload: PermissionsRequestDto, + id: String, + ) -> Response { + if let Err((status, message)) = validate_request(&payload) { + return common_response(status, &message); + } + let repo = PermissionsRepository::new(state); + match repo + .query_update_permission(PermissionsSchema { + id: make_thing(&ResourceEnum::Permissions.to_string(), &id), + name: payload.name, + ..Default::default() + }) + .await + { + Ok(msg) => common_response(StatusCode::OK, &msg), + Err(e) => { + if e.to_string().contains("not found") { + common_response(StatusCode::NOT_FOUND, "Permission not found") + } else { + common_response(StatusCode::BAD_REQUEST, &e.to_string()) + } + } + } + } + + pub async fn delete_permission(state: &AppState, id: String) -> Response { + let repo = PermissionsRepository::new(state); + match repo.query_delete_permission(id).await { + Ok(msg) => common_response(StatusCode::OK, &msg), + Err(e) => { + if e.to_string().contains("not found") { + common_response(StatusCode::NOT_FOUND, "Permission not found") + } else { + common_response(StatusCode::BAD_REQUEST, &e.to_string()) + } + } + } + } +}