Merge pull request 'feat: implement idempotency key for critical API endpoints' (#14) from feature/activity-logs into main
Reviewed-on: #14
This commit is contained in:
commit
6e3fc43d86
@ -138,6 +138,7 @@ func (a *App) Initialize(cfg *config.Config) error {
|
|||||||
selfOrderHandler,
|
selfOrderHandler,
|
||||||
services.expenseService,
|
services.expenseService,
|
||||||
validators.expenseValidator,
|
validators.expenseValidator,
|
||||||
|
a.redisClient,
|
||||||
)
|
)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
137
internal/middleware/idempotency.go
Normal file
137
internal/middleware/idempotency.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
@ -9,6 +9,7 @@ import (
|
|||||||
"apskel-pos-be/internal/validator"
|
"apskel-pos-be/internal/validator"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Router struct {
|
type Router struct {
|
||||||
@ -53,9 +54,10 @@ type Router struct {
|
|||||||
expenseHandler *handler.ExpenseHandler
|
expenseHandler *handler.ExpenseHandler
|
||||||
authMiddleware *middleware.AuthMiddleware
|
authMiddleware *middleware.AuthMiddleware
|
||||||
customerAuthMiddleware *middleware.CustomerAuthMiddleware
|
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{
|
return &Router{
|
||||||
config: cfg,
|
config: cfg,
|
||||||
@ -99,6 +101,7 @@ func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authSer
|
|||||||
selfOrderHandler: selfOrderHandler,
|
selfOrderHandler: selfOrderHandler,
|
||||||
productOutletPriceHandler: handler.NewProductOutletPriceHandler(productOutletPriceService, productOutletPriceValidator),
|
productOutletPriceHandler: handler.NewProductOutletPriceHandler(productOutletPriceService, productOutletPriceValidator),
|
||||||
expenseHandler: handler.NewExpenseHandler(expenseService, expenseValidator),
|
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("", r.orderHandler.ListOrders)
|
||||||
orders.GET("/:id", r.orderHandler.GetOrderByID)
|
orders.GET("/:id", r.orderHandler.GetOrderByID)
|
||||||
orders.POST("", r.orderHandler.CreateOrder)
|
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", r.orderHandler.UpdateOrder)
|
||||||
orders.PUT("/:id/customer", r.orderHandler.SetOrderCustomer)
|
orders.PUT("/:id/customer", r.orderHandler.SetOrderCustomer)
|
||||||
orders.POST("/void", r.orderHandler.VoidOrder)
|
orders.POST("/void", middleware.IdempotencyMiddleware(r.redisClient), r.orderHandler.VoidOrder)
|
||||||
orders.POST("/:id/refund", r.orderHandler.RefundOrder)
|
orders.POST("/:id/refund", middleware.IdempotencyMiddleware(r.redisClient), r.orderHandler.RefundOrder)
|
||||||
orders.POST("/split-bill", r.orderHandler.SplitBill)
|
orders.POST("/split-bill", r.orderHandler.SplitBill)
|
||||||
}
|
}
|
||||||
|
|
||||||
payments := protected.Group("/payments")
|
payments := protected.Group("/payments")
|
||||||
payments.Use(r.authMiddleware.RequireAdminOrManager())
|
payments.Use(r.authMiddleware.RequireAdminOrManager())
|
||||||
{
|
{
|
||||||
payments.POST("", r.orderHandler.CreatePayment)
|
payments.POST("", middleware.IdempotencyMiddleware(r.redisClient), r.orderHandler.CreatePayment)
|
||||||
payments.POST("/:id/refund", r.orderHandler.RefundPayment)
|
payments.POST("/:id/refund", middleware.IdempotencyMiddleware(r.redisClient), r.orderHandler.RefundPayment)
|
||||||
}
|
}
|
||||||
|
|
||||||
paymentMethods := protected.Group("/payment-methods")
|
paymentMethods := protected.Group("/payment-methods")
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user