From 021ec152e99112ecb85eb30412018bbfa5ef3db6 Mon Sep 17 00:00:00 2001 From: Efril Date: Thu, 4 Jun 2026 00:49:45 +0700 Subject: [PATCH] feat: implement idempotency key for critical API endpoints --- internal/app/app.go | 1 + internal/middleware/idempotency.go | 137 +++++++++++++++++++++++++++++ internal/router/router.go | 15 ++-- 3 files changed, 147 insertions(+), 6 deletions(-) create mode 100644 internal/middleware/idempotency.go diff --git a/internal/app/app.go b/internal/app/app.go index dc6c7d2..c8352a8 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -138,6 +138,7 @@ func (a *App) Initialize(cfg *config.Config) error { selfOrderHandler, services.expenseService, validators.expenseValidator, + a.redisClient, ) return nil diff --git a/internal/middleware/idempotency.go b/internal/middleware/idempotency.go new file mode 100644 index 0000000..b3dc214 --- /dev/null +++ b/internal/middleware/idempotency.go @@ -0,0 +1,137 @@ +package middleware + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "time" + + "github.com/gin-gonic/gin" + "github.com/redis/go-redis/v9" +) + +const ( + IdempotencyKeyHeader = "X-Idempotency-Key" + idempotencyTTL = 24 * time.Hour + idempotencyPrefix = "idempotency:" +) + +type cachedResponse struct { + StatusCode int `json:"status_code"` + Headers map[string]string `json:"headers"` + Body string `json:"body"` +} + +// IdempotencyMiddleware returns a Gin middleware that ensures idempotent processing +// for mutating operations. Client must send X-Idempotency-Key header. +func IdempotencyMiddleware(redisClient *redis.Client) gin.HandlerFunc { + return func(c *gin.Context) { + key := c.GetHeader(IdempotencyKeyHeader) + if key == "" { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{ + "success": false, + "errors": []gin.H{ + { + "code": "missing_idempotency_key", + "entity": "IdempotencyMiddleware", + "cause": "X-Idempotency-Key header is required", + }, + }, + }) + return + } + + redisKey := fmt.Sprintf("%s%s", idempotencyPrefix, key) + ctx := context.Background() + + fmt.Printf("[DEBUG] IdempotencyMiddleware: key=%s redisKey=%s\n", key, redisKey) + + // Check if key already exists (request was already processed) + cached, err := redisClient.Get(ctx, redisKey).Result() + if err == nil { + // Key exists — return cached response + fmt.Printf("[DEBUG] IdempotencyMiddleware: cache HIT for key=%s\n", key) + var resp cachedResponse + if err := json.Unmarshal([]byte(cached), &resp); err == nil { + for k, v := range resp.Headers { + c.Writer.Header().Set(k, v) + } + c.Writer.Header().Set("X-Idempotent-Replay", "true") + c.Data(resp.StatusCode, "application/json", []byte(resp.Body)) + c.Abort() + return + } + } else { + fmt.Printf("[DEBUG] IdempotencyMiddleware: cache MISS for key=%s err=%v\n", key, err) + } + + // Mark key as in-progress to prevent concurrent duplicates + set, err := redisClient.SetNX(ctx, redisKey, "processing", idempotencyTTL).Result() + if err != nil { + // Redis error — proceed without idempotency (fail open) + c.Next() + return + } + if !set { + // Another request with the same key is being processed + c.AbortWithStatusJSON(http.StatusConflict, gin.H{ + "success": false, + "errors": []gin.H{ + { + "code": "request_in_progress", + "entity": "IdempotencyMiddleware", + "cause": "A request with this idempotency key is already being processed", + }, + }, + }) + return + } + + // Capture response using a custom writer + writer := &responseCapture{ + ResponseWriter: c.Writer, + body: &bytes.Buffer{}, + } + c.Writer = writer + + c.Next() + + // After handler completes, cache the response only if successful (2xx) + statusCode := writer.Status() + if statusCode >= 200 && statusCode < 300 { + resp := cachedResponse{ + StatusCode: statusCode, + Headers: map[string]string{ + "Content-Type": writer.Header().Get("Content-Type"), + }, + Body: writer.body.String(), + } + + respJSON, err := json.Marshal(resp) + if err == nil { + redisClient.Set(ctx, redisKey, string(respJSON), idempotencyTTL) + } + } else { + // Remove the in-progress key so the client can retry with the same key + redisClient.Del(ctx, redisKey) + } + } +} + +// responseCapture wraps gin.ResponseWriter to capture the response body +type responseCapture struct { + gin.ResponseWriter + body *bytes.Buffer +} + +func (w *responseCapture) Write(b []byte) (int, error) { + w.body.Write(b) + return w.ResponseWriter.Write(b) +} + +func (w *responseCapture) WriteString(s string) (int, error) { + w.body.WriteString(s) + return w.ResponseWriter.WriteString(s) +} diff --git a/internal/router/router.go b/internal/router/router.go index 4febd1b..3472b45 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -9,6 +9,7 @@ import ( "apskel-pos-be/internal/validator" "github.com/gin-gonic/gin" + "github.com/redis/go-redis/v9" ) type Router struct { @@ -53,9 +54,10 @@ type Router struct { expenseHandler *handler.ExpenseHandler authMiddleware *middleware.AuthMiddleware customerAuthMiddleware *middleware.CustomerAuthMiddleware + redisClient *redis.Client } -func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authService service.AuthService, authMiddleware *middleware.AuthMiddleware, userService *service.UserServiceImpl, userValidator *validator.UserValidatorImpl, organizationService service.OrganizationService, organizationValidator validator.OrganizationValidator, outletService service.OutletService, outletValidator validator.OutletValidator, outletSettingService service.OutletSettingService, categoryService service.CategoryService, categoryValidator validator.CategoryValidator, productService service.ProductService, productValidator validator.ProductValidator, productVariantService service.ProductVariantService, productVariantValidator validator.ProductVariantValidator, inventoryService service.InventoryService, inventoryValidator validator.InventoryValidator, orderService service.OrderService, orderValidator validator.OrderValidator, fileService service.FileService, fileValidator validator.FileValidator, customerService service.CustomerService, customerValidator validator.CustomerValidator, paymentMethodService service.PaymentMethodService, paymentMethodValidator validator.PaymentMethodValidator, analyticsService *service.AnalyticsServiceImpl, reportService service.ReportService, tableService *service.TableServiceImpl, tableValidator *validator.TableValidator, unitService handler.UnitService, ingredientService handler.IngredientService, productRecipeService service.ProductRecipeService, vendorService service.VendorService, vendorValidator validator.VendorValidator, purchaseOrderService service.PurchaseOrderService, purchaseOrderValidator validator.PurchaseOrderValidator, unitConverterService service.IngredientUnitConverterService, unitConverterValidator validator.IngredientUnitConverterValidator, chartOfAccountTypeService service.ChartOfAccountTypeService, chartOfAccountTypeValidator validator.ChartOfAccountTypeValidator, chartOfAccountService service.ChartOfAccountService, chartOfAccountValidator validator.ChartOfAccountValidator, accountService service.AccountService, accountValidator validator.AccountValidator, orderIngredientTransactionService service.OrderIngredientTransactionService, orderIngredientTransactionValidator validator.OrderIngredientTransactionValidator, gamificationService service.GamificationService, gamificationValidator validator.GamificationValidator, rewardService service.RewardService, rewardValidator validator.RewardValidator, campaignService service.CampaignService, campaignValidator validator.CampaignValidator, customerAuthService service.CustomerAuthService, customerAuthValidator validator.CustomerAuthValidator, customerPointsService service.CustomerPointsService, spinGameService service.SpinGameService, customerAuthMiddleware *middleware.CustomerAuthMiddleware, userDeviceService service.UserDeviceService, userDeviceValidator validator.UserDeviceValidator, notificationService service.NotificationService, notificationValidator validator.NotificationValidator, productOutletPriceService service.ProductOutletPriceService, productOutletPriceValidator validator.ProductOutletPriceValidator, selfOrderHandler *handler.SelfOrderHandler, expenseService *service.ExpenseServiceImpl, expenseValidator *validator.ExpenseValidatorImpl) *Router { +func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authService service.AuthService, authMiddleware *middleware.AuthMiddleware, userService *service.UserServiceImpl, userValidator *validator.UserValidatorImpl, organizationService service.OrganizationService, organizationValidator validator.OrganizationValidator, outletService service.OutletService, outletValidator validator.OutletValidator, outletSettingService service.OutletSettingService, categoryService service.CategoryService, categoryValidator validator.CategoryValidator, productService service.ProductService, productValidator validator.ProductValidator, productVariantService service.ProductVariantService, productVariantValidator validator.ProductVariantValidator, inventoryService service.InventoryService, inventoryValidator validator.InventoryValidator, orderService service.OrderService, orderValidator validator.OrderValidator, fileService service.FileService, fileValidator validator.FileValidator, customerService service.CustomerService, customerValidator validator.CustomerValidator, paymentMethodService service.PaymentMethodService, paymentMethodValidator validator.PaymentMethodValidator, analyticsService *service.AnalyticsServiceImpl, reportService service.ReportService, tableService *service.TableServiceImpl, tableValidator *validator.TableValidator, unitService handler.UnitService, ingredientService handler.IngredientService, productRecipeService service.ProductRecipeService, vendorService service.VendorService, vendorValidator validator.VendorValidator, purchaseOrderService service.PurchaseOrderService, purchaseOrderValidator validator.PurchaseOrderValidator, unitConverterService service.IngredientUnitConverterService, unitConverterValidator validator.IngredientUnitConverterValidator, chartOfAccountTypeService service.ChartOfAccountTypeService, chartOfAccountTypeValidator validator.ChartOfAccountTypeValidator, chartOfAccountService service.ChartOfAccountService, chartOfAccountValidator validator.ChartOfAccountValidator, accountService service.AccountService, accountValidator validator.AccountValidator, orderIngredientTransactionService service.OrderIngredientTransactionService, orderIngredientTransactionValidator validator.OrderIngredientTransactionValidator, gamificationService service.GamificationService, gamificationValidator validator.GamificationValidator, rewardService service.RewardService, rewardValidator validator.RewardValidator, campaignService service.CampaignService, campaignValidator validator.CampaignValidator, customerAuthService service.CustomerAuthService, customerAuthValidator validator.CustomerAuthValidator, customerPointsService service.CustomerPointsService, spinGameService service.SpinGameService, customerAuthMiddleware *middleware.CustomerAuthMiddleware, userDeviceService service.UserDeviceService, userDeviceValidator validator.UserDeviceValidator, notificationService service.NotificationService, notificationValidator validator.NotificationValidator, productOutletPriceService service.ProductOutletPriceService, productOutletPriceValidator validator.ProductOutletPriceValidator, selfOrderHandler *handler.SelfOrderHandler, expenseService *service.ExpenseServiceImpl, expenseValidator *validator.ExpenseValidatorImpl, redisClient *redis.Client) *Router { return &Router{ config: cfg, @@ -99,6 +101,7 @@ func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authSer selfOrderHandler: selfOrderHandler, productOutletPriceHandler: handler.NewProductOutletPriceHandler(productOutletPriceService, productOutletPriceValidator), expenseHandler: handler.NewExpenseHandler(expenseService, expenseValidator), + redisClient: redisClient, } } @@ -274,19 +277,19 @@ func (r *Router) addAppRoutes(rg *gin.Engine) { orders.GET("", r.orderHandler.ListOrders) orders.GET("/:id", r.orderHandler.GetOrderByID) orders.POST("", r.orderHandler.CreateOrder) - orders.POST("/:id/add-items", r.orderHandler.AddToOrder) + orders.POST("/:id/add-items", middleware.IdempotencyMiddleware(r.redisClient), r.orderHandler.AddToOrder) orders.PUT("/:id", r.orderHandler.UpdateOrder) orders.PUT("/:id/customer", r.orderHandler.SetOrderCustomer) - orders.POST("/void", r.orderHandler.VoidOrder) - orders.POST("/:id/refund", r.orderHandler.RefundOrder) + orders.POST("/void", middleware.IdempotencyMiddleware(r.redisClient), r.orderHandler.VoidOrder) + orders.POST("/:id/refund", middleware.IdempotencyMiddleware(r.redisClient), r.orderHandler.RefundOrder) orders.POST("/split-bill", r.orderHandler.SplitBill) } payments := protected.Group("/payments") payments.Use(r.authMiddleware.RequireAdminOrManager()) { - payments.POST("", r.orderHandler.CreatePayment) - payments.POST("/:id/refund", r.orderHandler.RefundPayment) + payments.POST("", middleware.IdempotencyMiddleware(r.redisClient), r.orderHandler.CreatePayment) + payments.POST("/:id/refund", middleware.IdempotencyMiddleware(r.redisClient), r.orderHandler.RefundPayment) } paymentMethods := protected.Group("/payment-methods") -- 2.47.2