Implement rate limiting middleware for authentication endpoints, adding security headers middleware, and comprehensive error handling. Enhance validation tests for various DTOs and ensure proper functionality of gacha credits and rolls. Add unit tests for rate limiting and security headers middleware to validate behavior under different conditions.
This commit is contained in:
@@ -40,9 +40,4 @@ pub use imphnen_utils::{
|
||||
};
|
||||
|
||||
// Re-export public v1 API
|
||||
pub use v1::{
|
||||
gacha_claim_router,
|
||||
gacha_item_router,
|
||||
gacha_roll_router,
|
||||
gacha_router,
|
||||
};
|
||||
pub use v1::gacha_router;
|
||||
|
||||
@@ -1,15 +1,34 @@
|
||||
use crate::v1::gacha_items::GachaItemDto;
|
||||
use crate::v1::gacha_items::gacha_items_schema::GachaItemSchema;
|
||||
use imphnen_iam::{UsersDetailItemDto, UsersDetailQueryDto};
|
||||
use lazy_static::lazy_static;
|
||||
use regex::Regex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use surrealdb::sql::Thing;
|
||||
use utoipa::ToSchema;
|
||||
use validator::Validate;
|
||||
use validator::{Validate, ValidationError};
|
||||
|
||||
// Custom validator for user ID format (UUID-like validation)
|
||||
pub fn validate_user_id_format(user_id: &str) -> Result<(), ValidationError> {
|
||||
lazy_static! {
|
||||
static ref UUID_REGEX: Regex = Regex::new(r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$").unwrap();
|
||||
}
|
||||
if UUID_REGEX.is_match(user_id) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ValidationError::new("invalid_format"))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
|
||||
pub struct GachaClaimRequestDto {
|
||||
#[validate(length(min = 1, message = "User ID must not be empty"))]
|
||||
#[validate(custom(
|
||||
function = "validate_user_id_format",
|
||||
message = "User ID must be a valid UUID"
|
||||
))]
|
||||
pub user_id: String,
|
||||
|
||||
#[validate(length(min = 1, message = "Item ID must not be empty"))]
|
||||
pub item_id: String,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
use axum::{
|
||||
extract::Json,
|
||||
http::HeaderMap,
|
||||
response::Response,
|
||||
Extension,
|
||||
};
|
||||
use crate::AppState;
|
||||
use crate::v1::gacha_credits::gacha_credits_dto::GachaCreditRequestDto;
|
||||
use crate::v1::gacha_credits::gacha_credits_service::GachaCreditService;
|
||||
|
||||
pub struct GachaCreditController;
|
||||
|
||||
impl GachaCreditController {
|
||||
pub async fn get_user_credits(
|
||||
headers: HeaderMap,
|
||||
Extension(state): Extension<AppState>,
|
||||
) -> Response {
|
||||
GachaCreditService::get_user_credits(&headers, &state).await
|
||||
}
|
||||
|
||||
pub async fn add_user_credits(
|
||||
headers: HeaderMap,
|
||||
Extension(state): Extension<AppState>,
|
||||
Json(payload): Json<GachaCreditRequestDto>,
|
||||
) -> Response {
|
||||
GachaCreditService::add_user_credits(&headers, &state, payload).await
|
||||
}
|
||||
|
||||
pub async fn consume_user_credit(
|
||||
headers: HeaderMap,
|
||||
Extension(state): Extension<AppState>,
|
||||
) -> Response {
|
||||
GachaCreditService::consume_user_credit(&headers, &state).await
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,38 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
use validator::Validate;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, Validate)]
|
||||
pub struct GachaCreditRequestDto {
|
||||
#[validate(length(min = 1, message = "User ID must not be empty"))]
|
||||
pub user_id: String,
|
||||
|
||||
#[validate(range(
|
||||
min = 1,
|
||||
message = "Amount must be at least 1 credit"
|
||||
))]
|
||||
pub amount: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GachaCreditResponseDto {
|
||||
pub id: String,
|
||||
pub user_id: String,
|
||||
pub available_rolls: i32,
|
||||
pub is_deleted: bool,
|
||||
pub created_at: Option<String>,
|
||||
pub updated_at: Option<String>,
|
||||
}
|
||||
|
||||
impl From<&crate::v1::gacha_credits::gacha_credits_schema::GachaCreditSchema> for GachaCreditResponseDto {
|
||||
fn from(credit: &crate::v1::gacha_credits::gacha_credits_schema::GachaCreditSchema) -> Self {
|
||||
Self {
|
||||
id: credit.id.id.to_raw(),
|
||||
user_id: credit.user.id.to_raw(),
|
||||
available_rolls: credit.available_rolls,
|
||||
is_deleted: credit.is_deleted,
|
||||
created_at: credit.created_at.clone(),
|
||||
updated_at: credit.updated_at.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,9 +25,9 @@ impl<'a> GachaCreditRepository<'a> {
|
||||
let now = Instant::now();
|
||||
let db = &self.state.surrealdb_ws;
|
||||
let sql = format!(
|
||||
"SELECT * FROM {} WHERE user = {}:⟨$user_id⟩ AND is_deleted = false LIMIT 1",
|
||||
"SELECT * FROM {} WHERE user = type::thing('{}', $user_id) AND is_deleted = false LIMIT 1",
|
||||
ResourceEnum::GachaCredits,
|
||||
ResourceEnum::Users
|
||||
ResourceEnum::Users.as_str()
|
||||
);
|
||||
info!(query = %sql, "Executing SurrealDB query");
|
||||
let result: Vec<GachaCreditSchema> =
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
use axum::{Router, routing::get};
|
||||
use axum::routing::post;
|
||||
|
||||
pub fn gacha_credit_router() -> Router {
|
||||
Router::new()
|
||||
.route("/", get(crate::v1::gacha_credits::GachaCreditController::get_user_credits))
|
||||
.route("/add", post(crate::v1::gacha_credits::GachaCreditController::add_user_credits))
|
||||
.route("/consume", post(crate::v1::gacha_credits::GachaCreditController::consume_user_credit))
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
use crate::AppState;
|
||||
use imphnen_entities::ResponseSuccessDto;
|
||||
use imphnen_utils::{errors::AppError, error_response};
|
||||
use imphnen_utils::{common_response, success_response, validate_request};
|
||||
use crate::v1::gacha_credits::gacha_credits_dto::{GachaCreditRequestDto, GachaCreditResponseDto};
|
||||
use crate::v1::gacha_credits::gacha_credits_repository::GachaCreditRepository;
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::Response;
|
||||
use imphnen_iam::UsersRepository;
|
||||
use imphnen_utils::extract_email;
|
||||
|
||||
pub struct GachaCreditService;
|
||||
|
||||
impl GachaCreditService {
|
||||
pub async fn get_user_credits(headers: &axum::http::HeaderMap, state: &AppState) -> Response {
|
||||
let repo = GachaCreditRepository::new(state);
|
||||
let repo_user = UsersRepository::new(state);
|
||||
let Some(email) = extract_email(headers) else {
|
||||
return error_response(AppError::AuthenticationError("Unauthorized".into()));
|
||||
};
|
||||
|
||||
let Ok(user) = repo_user.query_user_by_email(email.to_string()).await else {
|
||||
return error_response(AppError::NotFoundError("User not found".into()));
|
||||
};
|
||||
|
||||
match repo.query_by_user_id(user.id.id.to_raw()).await {
|
||||
Ok(Some(credit)) => {
|
||||
let response_dto = GachaCreditResponseDto::from(&credit);
|
||||
success_response(ResponseSuccessDto { data: response_dto })
|
||||
}
|
||||
Ok(None) => {
|
||||
// Return empty credits if no record exists
|
||||
let response_dto = GachaCreditResponseDto {
|
||||
id: "".to_string(),
|
||||
user_id: user.id.id.to_raw(),
|
||||
available_rolls: 0,
|
||||
is_deleted: false,
|
||||
created_at: None,
|
||||
updated_at: None,
|
||||
};
|
||||
success_response(ResponseSuccessDto { data: response_dto })
|
||||
}
|
||||
Err(e) => error_response(AppError::InternalServerError(e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn add_user_credits(
|
||||
headers: &axum::http::HeaderMap,
|
||||
state: &AppState,
|
||||
payload: GachaCreditRequestDto,
|
||||
) -> Response {
|
||||
if let Err((status, message)) = validate_request(&payload) {
|
||||
return common_response(status, &message);
|
||||
}
|
||||
|
||||
let repo = GachaCreditRepository::new(state);
|
||||
let repo_user = UsersRepository::new(state);
|
||||
let Some(email) = extract_email(headers) else {
|
||||
return error_response(AppError::AuthenticationError("Unauthorized".into()));
|
||||
};
|
||||
|
||||
let Ok(user) = repo_user.query_user_by_email(email.to_string()).await else {
|
||||
return error_response(AppError::NotFoundError("User not found".into()));
|
||||
};
|
||||
|
||||
// Ensure the user can only modify their own credits
|
||||
if payload.user_id != user.id.id.to_raw() {
|
||||
return error_response(AppError::AuthorizationError("You can only modify your own credits".into()));
|
||||
}
|
||||
|
||||
let amount = payload.amount; // Extract amount before moving payload
|
||||
match repo.query_add_credit(payload).await {
|
||||
Ok(_) => common_response(
|
||||
StatusCode::OK,
|
||||
&format!("Added {} credits successfully", amount)
|
||||
),
|
||||
Err(e) => error_response(AppError::InternalServerError(e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn consume_user_credit(headers: &axum::http::HeaderMap, state: &AppState) -> Response {
|
||||
let repo = GachaCreditRepository::new(state);
|
||||
let repo_user = UsersRepository::new(state);
|
||||
let Some(email) = extract_email(headers) else {
|
||||
return error_response(AppError::AuthenticationError("Unauthorized".into()));
|
||||
};
|
||||
|
||||
let Ok(user) = repo_user.query_user_by_email(email.to_string()).await else {
|
||||
return error_response(AppError::NotFoundError("User not found".into()));
|
||||
};
|
||||
|
||||
match repo.query_consume_credit(user.id.id.to_raw()).await {
|
||||
Ok(_) => common_response(StatusCode::OK, "Consumed 1 credit successfully"),
|
||||
Err(e) => error_response(AppError::BadRequestError(e.to_string())),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,13 @@
|
||||
pub mod gacha_credits_controller;
|
||||
pub mod gacha_credits_dto;
|
||||
pub mod gacha_credits_repository;
|
||||
pub mod gacha_credits_schema;
|
||||
pub mod gacha_credits_service;
|
||||
pub mod gacha_credits_router;
|
||||
|
||||
// Export only public types and functions
|
||||
pub use gacha_credits_dto::GachaCreditRequestDto;
|
||||
pub use gacha_credits_controller::GachaCreditController;
|
||||
pub use gacha_credits_dto::{GachaCreditRequestDto, GachaCreditResponseDto};
|
||||
pub use gacha_credits_repository::GachaCreditRepository;
|
||||
pub use gacha_credits_service::GachaCreditService;
|
||||
pub use gacha_credits_router::gacha_credit_router;
|
||||
|
||||
@@ -1,22 +1,46 @@
|
||||
use crate::v1::gacha_items::gacha_items_schema::GachaItemSchema;
|
||||
use lazy_static::lazy_static;
|
||||
use regex::Regex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
use validator::Validate;
|
||||
use validator::{Validate, ValidationError};
|
||||
|
||||
// Custom validator for image URLs
|
||||
pub fn validate_image_url(url: &str) -> Result<(), ValidationError> {
|
||||
lazy_static! {
|
||||
static ref IMAGE_URL_REGEX: Regex = Regex::new(r"^https?://[^\s]+\.(jpg|jpeg|png|gif|webp)$").unwrap();
|
||||
}
|
||||
if IMAGE_URL_REGEX.is_match(url) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ValidationError::new("invalid_image_url"))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
|
||||
pub struct GachaItemRequestDto {
|
||||
#[validate(length(min = 1, message = "Item name must not be empty"))]
|
||||
#[validate(length(min = 1, max = 100, message = "Item name must be between 1 and 100 characters"))]
|
||||
pub name: String,
|
||||
|
||||
#[validate(length(min = 1, message = "Image URL must not be empty"))]
|
||||
#[validate(custom(
|
||||
function = "validate_image_url",
|
||||
message = "Image URL must be a valid URL pointing to JPG, JPEG, PNG, GIF, or WebP image"
|
||||
))]
|
||||
pub image_url: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
|
||||
pub struct GachaItemUpdateRequestDto {
|
||||
#[validate(length(min = 1, message = "Item name must not be empty"))]
|
||||
#[validate(length(min = 1, max = 100, message = "Item name must be between 1 and 100 characters"))]
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
|
||||
#[validate(length(min = 1, message = "Image URL must not be empty"))]
|
||||
#[validate(custom(
|
||||
function = "validate_image_url",
|
||||
message = "Image URL must be a valid URL pointing to JPG, JPEG, PNG, GIF, or WebP image"
|
||||
))]
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub image_url: Option<String>,
|
||||
}
|
||||
|
||||
@@ -7,10 +7,13 @@ use validator::Validate;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
|
||||
pub struct GachaRollRequestDto {
|
||||
#[validate(length(min = 1, message = "Item ID must not be empty"))]
|
||||
#[validate(length(min = 1, max = 100, message = "Item ID must be between 1 and 100 characters"))]
|
||||
pub item_id: String,
|
||||
|
||||
#[validate(range(min = 0.0, max = 1.0, message = "Weight must be between 0.0 and 1.0"))]
|
||||
pub weight: f32,
|
||||
#[validate(range(min = 1, message = "Quantity must be at least 1"))]
|
||||
|
||||
#[validate(range(min = 1, max = 100, message = "Quantity must be between 1 and 100"))]
|
||||
pub quantity: i32,
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ use crate::v1::gacha_claims::gacha_claims_schema::GachaClaimSchema;
|
||||
use crate::v1::gacha_rolls::gacha_rolls_dto::{GachaRollItemDto, GachaRollRequestDto};
|
||||
use crate::v1::gacha_rolls::gacha_rolls_repository::GachaRollRepository;
|
||||
use crate::v1::gacha_rolls::gacha_rolls_schema::GachaRollSchema;
|
||||
use crate::v1::gacha_credits::gacha_credits_repository::GachaCreditRepository;
|
||||
use axum::http::{HeaderMap, StatusCode};
|
||||
use axum::response::Response;
|
||||
use imphnen_iam::UsersRepository;
|
||||
@@ -40,33 +41,63 @@ impl GachaRollService {
|
||||
}
|
||||
|
||||
pub async fn execute_roll_once(headers: HeaderMap, state: &AppState) -> Response {
|
||||
let repo = GachaRollRepository::new(state);
|
||||
let repo_claim = GachaClaimRepository::new(state);
|
||||
let repo_user = UsersRepository::new(state);
|
||||
let Some(email) = extract_email(&headers) else {
|
||||
return common_response(StatusCode::UNAUTHORIZED, "Unauthorized");
|
||||
};
|
||||
let Ok(user) = repo_user.query_user_by_email(email.to_string()).await else {
|
||||
return common_response(StatusCode::NOT_FOUND, "User not found");
|
||||
};
|
||||
match repo.query_all_active_rolls().await {
|
||||
Ok(rolls) => match GachaRollRepository::roll_once(&rolls) {
|
||||
Some(roll) => {
|
||||
let claim = GachaClaimSchema::roll(roll.clone(), user.id);
|
||||
match repo_claim.query_create_gacha_claim(claim).await {
|
||||
Ok(_) => success_response(ResponseSuccessDto {
|
||||
data: GachaRollItemDto::from(&roll),
|
||||
}),
|
||||
Err(e) => {
|
||||
common_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string())
|
||||
let repo = GachaRollRepository::new(state);
|
||||
let repo_claim = GachaClaimRepository::new(state);
|
||||
let repo_user = UsersRepository::new(state);
|
||||
let repo_credits = GachaCreditRepository::new(state);
|
||||
let Some(email) = extract_email(&headers) else {
|
||||
return common_response(StatusCode::UNAUTHORIZED, "Unauthorized");
|
||||
};
|
||||
let Ok(user) = repo_user.query_user_by_email(email.to_string()).await else {
|
||||
return common_response(StatusCode::NOT_FOUND, "User not found");
|
||||
};
|
||||
|
||||
// Check if user has enough credits
|
||||
let credit_opt = repo_credits.query_by_user_id(user.id.id.to_raw()).await;
|
||||
let has_enough_credits = match credit_opt {
|
||||
Ok(Some(credit)) => credit.available_rolls > 0,
|
||||
Ok(None) => false, // No credit record means no credits
|
||||
Err(e) => {
|
||||
return common_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string())
|
||||
}
|
||||
};
|
||||
|
||||
if !has_enough_credits {
|
||||
return common_response(StatusCode::PAYMENT_REQUIRED, "Not enough credits to perform this action");
|
||||
}
|
||||
|
||||
// Consume one credit
|
||||
match repo_credits.query_consume_credit(user.id.id.to_raw()).await {
|
||||
Err(e) => return common_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// Proceed with the roll
|
||||
match repo.query_all_active_rolls().await {
|
||||
Ok(rolls) => match GachaRollRepository::roll_once(&rolls) {
|
||||
Some(roll) => {
|
||||
let user_id_clone = user.id.clone();
|
||||
let claim = GachaClaimSchema::roll(roll.clone(), user_id_clone);
|
||||
match repo_claim.query_create_gacha_claim(claim).await {
|
||||
Ok(_) => success_response(ResponseSuccessDto {
|
||||
data: GachaRollItemDto::from(&roll),
|
||||
}),
|
||||
Err(e) => {
|
||||
// Refund the credit if claim creation fails
|
||||
let user_id = user.id.id.to_raw(); // Extract value before potential move
|
||||
let _ = repo_credits.query_add_credit(crate::v1::gacha_credits::gacha_credits_dto::GachaCreditRequestDto {
|
||||
user_id,
|
||||
amount: 1,
|
||||
}).await;
|
||||
common_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None => common_response(StatusCode::NOT_FOUND, "No rollable item available"),
|
||||
},
|
||||
Err(e) => common_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
||||
None => common_response(StatusCode::NOT_FOUND, "No rollable item available"),
|
||||
},
|
||||
Err(e) => common_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn soft_delete_gacha_roll(state: &AppState, id: String) -> Response {
|
||||
let repo = GachaRollRepository::new(state);
|
||||
|
||||
@@ -6,15 +6,17 @@ pub mod gacha_items;
|
||||
pub mod gacha_rolls;
|
||||
|
||||
// Export only public router functions to avoid namespace pollution
|
||||
pub use gacha_claims::gacha_claim_router;
|
||||
pub use gacha_credits::*; // gacha_credits doesn't have router functions
|
||||
pub use gacha_credits::gacha_credit_router;
|
||||
pub use gacha_items::gacha_item_router;
|
||||
pub use gacha_rolls::gacha_roll_router;
|
||||
pub use gacha_claims::gacha_claim_router;
|
||||
|
||||
/// Creates the main gacha router with all version 1 endpoints
|
||||
pub fn gacha_router() -> Router {
|
||||
Router::new()
|
||||
.nest("/claims", gacha_claim_router())
|
||||
.nest("/items", gacha_item_router())
|
||||
.nest("/rolls", gacha_roll_router())
|
||||
let mut router = Router::new();
|
||||
router = router.nest("/credits", gacha_credit_router());
|
||||
router = router.nest("/items", gacha_item_router());
|
||||
router = router.nest("/rolls", gacha_roll_router());
|
||||
router = router.nest("/claims", gacha_claim_router());
|
||||
router
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user