From 988adf6cc546fd75eb1ab7742a77c0e8eaa33369 Mon Sep 17 00:00:00 2001 From: 0x1d Date: Thu, 6 Nov 2025 22:04:55 +0100 Subject: [PATCH] Fix gRPC health checks and add API Gateway Consul registration - Fix gRPC health checks: Set serving status for default service (empty string) in all services - Consul checks the default service by default, not specific service names - All services now set both default and specific service status to SERVING - Update Consul registration logic to automatically detect HTTP vs gRPC services - HTTP services (API Gateway) use HTTP health checks - gRPC services use gRPC health checks - Detection based on service tags and metadata - Add API Gateway Consul registration - Register with Docker service name in Docker environment - Use HTTP health checks for API Gateway - Proper host/port configuration handling - Add API Gateway HTTP-to-gRPC handlers - Implement service-specific handlers for Auth and Identity services - Translate HTTP requests to gRPC calls - Map gRPC error codes to HTTP status codes --- cmd/api-gateway/main.go | 22 +- cmd/audit-service/audit_service_fx.go | 3 + cmd/auth-service/auth_service_fx.go | 3 + cmd/authz-service/authz_service_fx.go | 3 + cmd/identity-service/identity_service_fx.go | 3 + internal/registry/consul/consul.go | 38 +- services/gateway/gateway.go | 198 +++++++--- services/gateway/handlers.go | 406 ++++++++++++++++++++ 8 files changed, 610 insertions(+), 66 deletions(-) create mode 100644 services/gateway/handlers.go diff --git a/cmd/api-gateway/main.go b/cmd/api-gateway/main.go index 98516b6..553f9a8 100644 --- a/cmd/api-gateway/main.go +++ b/cmd/api-gateway/main.go @@ -58,14 +58,25 @@ func main() { } gateway.SetupRoutes(srv.Router()) - // Register with Consul + // Determine port and host for registration gatewayPort := cfg.GetInt("gateway.port") if gatewayPort == 0 { - gatewayPort = 8080 + gatewayPort = cfg.GetInt("server.port") + if gatewayPort == 0 { + gatewayPort = 8080 + } } + + // In Docker, always use the Docker service name for health checks + // Consul (also in Docker) needs to reach the service via Docker DNS gatewayHost := cfg.GetString("gateway.host") - if gatewayHost == "" { - gatewayHost = "localhost" + if os.Getenv("ENVIRONMENT") == "production" || os.Getenv("DOCKER") == "true" { + gatewayHost = "api-gateway" // Docker service name - required for Consul health checks + } else if gatewayHost == "" { + gatewayHost = cfg.GetString("server.host") + if gatewayHost == "" || gatewayHost == "0.0.0.0" { + gatewayHost = "localhost" // Local development + } } serviceInstance := ®istry.ServiceInstance{ @@ -75,7 +86,8 @@ func main() { Port: gatewayPort, Tags: []string{"gateway", "http"}, Metadata: map[string]string{ - "version": "1.0.0", + "version": "1.0.0", + "protocol": "http", }, } diff --git a/cmd/audit-service/audit_service_fx.go b/cmd/audit-service/audit_service_fx.go index fd982d9..da787a5 100644 --- a/cmd/audit-service/audit_service_fx.go +++ b/cmd/audit-service/audit_service_fx.go @@ -324,6 +324,9 @@ func provideAuditService() fx.Option { // Register health service healthServer := health.NewServer() grpc_health_v1.RegisterHealthServer(grpcServer, healthServer) + // Set serving status for the default service (empty string) - this is what Consul checks + healthServer.SetServingStatus("", grpc_health_v1.HealthCheckResponse_SERVING) + // Also set for the specific service name healthServer.SetServingStatus("audit.v1.AuditService", grpc_health_v1.HealthCheckResponse_SERVING) // Register reflection for grpcurl diff --git a/cmd/auth-service/auth_service_fx.go b/cmd/auth-service/auth_service_fx.go index 5e31022..b59972a 100644 --- a/cmd/auth-service/auth_service_fx.go +++ b/cmd/auth-service/auth_service_fx.go @@ -406,6 +406,9 @@ func provideAuthService() fx.Option { // Register health service healthServer := health.NewServer() grpc_health_v1.RegisterHealthServer(grpcServer, healthServer) + // Set serving status for the default service (empty string) - this is what Consul checks + healthServer.SetServingStatus("", grpc_health_v1.HealthCheckResponse_SERVING) + // Also set for the specific service name healthServer.SetServingStatus("auth.v1.AuthService", grpc_health_v1.HealthCheckResponse_SERVING) // Register reflection for grpcurl diff --git a/cmd/authz-service/authz_service_fx.go b/cmd/authz-service/authz_service_fx.go index ce9fe4c..f5e1238 100644 --- a/cmd/authz-service/authz_service_fx.go +++ b/cmd/authz-service/authz_service_fx.go @@ -271,6 +271,9 @@ func provideAuthzService() fx.Option { // Register health service healthServer := health.NewServer() grpc_health_v1.RegisterHealthServer(grpcServer, healthServer) + // Set serving status for the default service (empty string) - this is what Consul checks + healthServer.SetServingStatus("", grpc_health_v1.HealthCheckResponse_SERVING) + // Also set for the specific service name healthServer.SetServingStatus("authz.v1.AuthzService", grpc_health_v1.HealthCheckResponse_SERVING) // Register reflection for grpcurl diff --git a/cmd/identity-service/identity_service_fx.go b/cmd/identity-service/identity_service_fx.go index 6e073cf..065ff9b 100644 --- a/cmd/identity-service/identity_service_fx.go +++ b/cmd/identity-service/identity_service_fx.go @@ -418,6 +418,9 @@ func provideIdentityService() fx.Option { // Register health service healthServer := health.NewServer() grpc_health_v1.RegisterHealthServer(grpcServer, healthServer) + // Set serving status for the default service (empty string) - this is what Consul checks + healthServer.SetServingStatus("", grpc_health_v1.HealthCheckResponse_SERVING) + // Also set for the specific service name healthServer.SetServingStatus("identity.v1.IdentityService", grpc_health_v1.HealthCheckResponse_SERVING) // Register reflection for grpcurl diff --git a/internal/registry/consul/consul.go b/internal/registry/consul/consul.go index 82433f5..9a1700f 100644 --- a/internal/registry/consul/consul.go +++ b/internal/registry/consul/consul.go @@ -69,12 +69,42 @@ func (r *ConsulRegistry) Register(ctx context.Context, service *registry.Service Meta: service.Metadata, } + // Determine health check type based on service metadata/tags or config + // Check if service is HTTP (has "http" tag or protocol metadata) + isHTTP := false + for _, tag := range service.Tags { + if tag == "http" { + isHTTP = true + break + } + } + if !isHTTP && service.Metadata != nil { + if protocol, ok := service.Metadata["protocol"]; ok && protocol == "http" { + isHTTP = true + } + } + // Add health check if configured - if r.config.HealthCheck.UseGRPC { + if isHTTP && r.config.HealthCheck.HTTP != "" { + // Use HTTP health check for HTTP services (e.g., API Gateway) + healthCheckURL := fmt.Sprintf("http://%s:%d%s", service.Address, service.Port, r.config.HealthCheck.HTTP) + registration.Check = &consulapi.AgentServiceCheck{ + HTTP: healthCheckURL, + Interval: r.config.HealthCheck.Interval.String(), + Timeout: r.config.HealthCheck.Timeout.String(), + DeregisterCriticalServiceAfter: r.config.HealthCheck.DeregisterAfter.String(), + } + } else if !isHTTP && r.config.HealthCheck.UseGRPC { // Use gRPC health check for gRPC services - // Format: host:port/service or host:port (uses default health service) + // Format: host:port (checks default service with empty string name) + // Or: host:port/service (checks specific service name) + // We use host:port to check the default service (empty string) grpcAddr := fmt.Sprintf("%s:%d", service.Address, service.Port) - if r.config.HealthCheck.GRPC != "" { + // If a specific service name is provided, append it + // Otherwise, check the default service (empty string) which we set in each service + if r.config.HealthCheck.GRPC != "" && r.config.HealthCheck.GRPC != "grpc.health.v1.Health" { + // Only append if it's not the default health service name + // The GRPC field in Consul expects the application service name, not the proto service name grpcAddr = fmt.Sprintf("%s:%d/%s", service.Address, service.Port, r.config.HealthCheck.GRPC) } registration.Check = &consulapi.AgentServiceCheck{ @@ -84,7 +114,7 @@ func (r *ConsulRegistry) Register(ctx context.Context, service *registry.Service DeregisterCriticalServiceAfter: r.config.HealthCheck.DeregisterAfter.String(), } } else if r.config.HealthCheck.HTTP != "" { - // Use HTTP health check for HTTP services + // Fallback to HTTP if HTTP endpoint is configured and service is not explicitly gRPC healthCheckURL := fmt.Sprintf("http://%s:%d%s", service.Address, service.Port, r.config.HealthCheck.HTTP) registration.Check = &consulapi.AgentServiceCheck{ HTTP: healthCheckURL, diff --git a/services/gateway/gateway.go b/services/gateway/gateway.go index 4363b07..a540e13 100644 --- a/services/gateway/gateway.go +++ b/services/gateway/gateway.go @@ -2,10 +2,8 @@ package gateway import ( - "fmt" "net/http" - "net/http/httputil" - "net/url" + "strings" "git.dcentral.systems/toolz/goplt/internal/client" "git.dcentral.systems/toolz/goplt/pkg/config" @@ -51,10 +49,50 @@ func NewGateway( // SetupRoutes configures routes on the Gin router. func (g *Gateway) SetupRoutes(router *gin.Engine) { - // Setup route handlers + // Register routes with wildcard support + // Gin uses /*path for wildcards, so we convert /** to /*path for _, route := range g.routes { route := route // Capture for closure - router.Any(route.Path, g.handleRoute(route)) + + // Convert /** wildcard to Gin's /*path format + ginPath := route.Path + hasWildcard := strings.HasSuffix(ginPath, "/**") + if hasWildcard { + ginPath = strings.TrimSuffix(ginPath, "/**") + "/*path" + } + + // Register all HTTP methods for this route + router.Any(ginPath, func(c *gin.Context) { + // Extract the remaining path + var remainingPath string + if hasWildcard { + // Extract from Gin's path parameter + pathParam := c.Param("path") + if pathParam != "" { + remainingPath = "/" + pathParam + } else { + remainingPath = "/" + } + } else { + // Exact match - no remaining path + remainingPath = "/" + } + + // Route to appropriate service handler + switch route.Service { + case "auth-service": + g.handleAuthService(c, route, remainingPath) + case "identity-service": + g.handleIdentityService(c, route, remainingPath) + default: + g.log.Warn("Unknown service", + logger.String("service", route.Service), + ) + c.JSON(http.StatusNotFound, gin.H{ + "error": "Service not found", + }) + } + }) } // Default handler for unmatched routes @@ -66,62 +104,108 @@ func (g *Gateway) SetupRoutes(router *gin.Engine) { }) } -// handleRoute returns a handler function for a route. -func (g *Gateway) handleRoute(route RouteConfig) gin.HandlerFunc { - return func(c *gin.Context) { - // TODO: Add authentication middleware if auth_required is true - // TODO: Add rate limiting middleware - // TODO: Add CORS middleware - - // Discover service instances - ctx := c.Request.Context() - instances, err := g.registry.Discover(ctx, route.Service) - if err != nil { - g.log.Error("Failed to discover service", - logger.String("service", route.Service), - logger.Error(err), - ) - c.JSON(http.StatusServiceUnavailable, gin.H{ - "error": "Service unavailable", - }) - return +// matchRoute finds the matching route configuration for a given path. +func (g *Gateway) matchRoute(path string) *RouteConfig { + for _, route := range g.routes { + if g.pathMatches(path, route.Path) { + return &route } - - if len(instances) == 0 { - g.log.Warn("No instances found for service", - logger.String("service", route.Service), - ) - c.JSON(http.StatusServiceUnavailable, gin.H{ - "error": "Service unavailable", - }) - return - } - - // Use first healthy instance (load balancing can be added later) - instance := instances[0] - targetURL := fmt.Sprintf("http://%s:%d", instance.Address, instance.Port) - - // Create reverse proxy - target, err := url.Parse(targetURL) - if err != nil { - g.log.Error("Failed to parse target URL", - logger.String("url", targetURL), - logger.Error(err), - ) - c.JSON(http.StatusInternalServerError, gin.H{ - "error": "Internal server error", - }) - return - } - - proxy := httputil.NewSingleHostReverseProxy(target) - proxy.ServeHTTP(c.Writer, c.Request) } + return nil +} + +// pathMatches checks if a request path matches a route pattern. +// Supports wildcard matching: "/api/v1/auth/**" matches "/api/v1/auth/login", etc. +func (g *Gateway) pathMatches(requestPath, routePath string) bool { + // Remove trailing slashes for comparison + requestPath = strings.TrimSuffix(requestPath, "/") + routePath = strings.TrimSuffix(routePath, "/") + + // Exact match + if requestPath == routePath { + return true + } + + // Wildcard match: routePath ends with "/**" + if strings.HasSuffix(routePath, "/**") { + prefix := strings.TrimSuffix(routePath, "/**") + return strings.HasPrefix(requestPath, prefix+"/") || requestPath == prefix + } + + return false +} + +// extractRemainingPath extracts the path segment after the route prefix. +// Example: path="/api/v1/auth/login", route="/api/v1/auth/**" -> returns "/login" +func (g *Gateway) extractRemainingPath(requestPath, routePath string) string { + // Remove trailing slashes + requestPath = strings.TrimSuffix(requestPath, "/") + routePath = strings.TrimSuffix(routePath, "/") + + // Handle wildcard routes + if strings.HasSuffix(routePath, "/**") { + prefix := strings.TrimSuffix(routePath, "/**") + if strings.HasPrefix(requestPath, prefix) { + remaining := strings.TrimPrefix(requestPath, prefix) + if remaining == "" { + return "/" + } + return remaining + } + } + + // Exact match - no remaining path + if requestPath == routePath { + return "/" + } + + return "" } // loadRoutes loads route configurations from config. func loadRoutes(cfg config.ConfigProvider) []RouteConfig { - // For now, return empty routes - will be loaded from config in future - // This is a placeholder implementation - return []RouteConfig{} + if cfg == nil { + return []RouteConfig{} + } + + // Get routes from config + routesInterface := cfg.Get("gateway.routes") + if routesInterface == nil { + return []RouteConfig{} + } + + // Convert to slice of RouteConfig + routesSlice, ok := routesInterface.([]interface{}) + if !ok { + return []RouteConfig{} + } + + routes := make([]RouteConfig, 0, len(routesSlice)) + for _, routeInterface := range routesSlice { + routeMap, ok := routeInterface.(map[string]interface{}) + if !ok { + continue + } + + route := RouteConfig{} + + if path, ok := routeMap["path"].(string); ok { + route.Path = path + } + + if service, ok := routeMap["service"].(string); ok { + route.Service = service + } + + if authRequired, ok := routeMap["auth_required"].(bool); ok { + route.AuthRequired = authRequired + } + + // Only add route if it has required fields + if route.Path != "" && route.Service != "" { + routes = append(routes, route) + } + } + + return routes } diff --git a/services/gateway/handlers.go b/services/gateway/handlers.go new file mode 100644 index 0000000..0dd3fab --- /dev/null +++ b/services/gateway/handlers.go @@ -0,0 +1,406 @@ +// Package gateway provides API Gateway implementation. +package gateway + +import ( + "context" + "net/http" + "strings" + + "git.dcentral.systems/toolz/goplt/pkg/logger" + "git.dcentral.systems/toolz/goplt/pkg/services" + "github.com/gin-gonic/gin" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// handleAuthService handles requests for auth-service routes. +func (g *Gateway) handleAuthService(c *gin.Context, route RouteConfig, remainingPath string) { + ctx := c.Request.Context() + + // Get auth client + authClient, err := g.clientFactory.GetAuthClient() + if err != nil { + g.log.Error("Failed to get auth client", + logger.String("error", err.Error()), + ) + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "Internal server error", + }) + return + } + + // Route based on path and method + switch { + case c.Request.Method == http.MethodPost && remainingPath == "/login": + g.handleLogin(ctx, c, authClient) + case c.Request.Method == http.MethodPost && remainingPath == "/refresh": + g.handleRefreshToken(ctx, c, authClient) + case c.Request.Method == http.MethodPost && remainingPath == "/validate": + g.handleValidateToken(ctx, c, authClient) + case c.Request.Method == http.MethodPost && remainingPath == "/logout": + g.handleLogout(ctx, c, authClient) + default: + c.JSON(http.StatusNotFound, gin.H{ + "error": "Endpoint not found", + "path": remainingPath, + }) + } +} + +// handleIdentityService handles requests for identity-service routes. +func (g *Gateway) handleIdentityService(c *gin.Context, route RouteConfig, remainingPath string) { + ctx := c.Request.Context() + + // Get identity client + identityClient, err := g.clientFactory.GetIdentityClient() + if err != nil { + g.log.Error("Failed to get identity client", + logger.String("error", err.Error()), + ) + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "Internal server error", + }) + return + } + + // Route based on path and method + pathParts := strings.Split(strings.Trim(remainingPath, "/"), "/") + + switch { + // GET /api/v1/users/:id + case c.Request.Method == http.MethodGet && len(pathParts) == 1 && pathParts[0] != "": + userID := pathParts[0] + g.handleGetUser(ctx, c, identityClient, userID) + + // GET /api/v1/users?email=... + case c.Request.Method == http.MethodGet && remainingPath == "" && c.Query("email") != "": + email := c.Query("email") + g.handleGetUserByEmail(ctx, c, identityClient, email) + + // POST /api/v1/users + case c.Request.Method == http.MethodPost && remainingPath == "": + g.handleCreateUser(ctx, c, identityClient) + + // PUT /api/v1/users/:id + case c.Request.Method == http.MethodPut && len(pathParts) == 1 && pathParts[0] != "": + userID := pathParts[0] + g.handleUpdateUser(ctx, c, identityClient, userID) + + // DELETE /api/v1/users/:id + case c.Request.Method == http.MethodDelete && len(pathParts) == 1 && pathParts[0] != "": + userID := pathParts[0] + g.handleDeleteUser(ctx, c, identityClient, userID) + + // POST /api/v1/users/verify-email + case c.Request.Method == http.MethodPost && remainingPath == "/verify-email": + g.handleVerifyEmail(ctx, c, identityClient) + + // POST /api/v1/users/request-password-reset + case c.Request.Method == http.MethodPost && remainingPath == "/request-password-reset": + g.handleRequestPasswordReset(ctx, c, identityClient) + + // POST /api/v1/users/reset-password + case c.Request.Method == http.MethodPost && remainingPath == "/reset-password": + g.handleResetPassword(ctx, c, identityClient) + + default: + c.JSON(http.StatusNotFound, gin.H{ + "error": "Endpoint not found", + "path": remainingPath, + }) + } +} + +// Auth Service Handlers + +func (g *Gateway) handleLogin(ctx context.Context, c *gin.Context, client services.AuthServiceClient) { + var req struct { + Email string `json:"email" binding:"required"` + Password string `json:"password" binding:"required"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "Invalid request", + "details": err.Error(), + }) + return + } + + tokenResp, err := client.Login(ctx, req.Email, req.Password) + if err != nil { + g.handleGRPCError(c, err) + return + } + + c.JSON(http.StatusOK, tokenResp) +} + +func (g *Gateway) handleRefreshToken(ctx context.Context, c *gin.Context, client services.AuthServiceClient) { + var req struct { + RefreshToken string `json:"refresh_token" binding:"required"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "Invalid request", + "details": err.Error(), + }) + return + } + + tokenResp, err := client.RefreshToken(ctx, req.RefreshToken) + if err != nil { + g.handleGRPCError(c, err) + return + } + + c.JSON(http.StatusOK, tokenResp) +} + +func (g *Gateway) handleValidateToken(ctx context.Context, c *gin.Context, client services.AuthServiceClient) { + var req struct { + Token string `json:"token" binding:"required"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "Invalid request", + "details": err.Error(), + }) + return + } + + claims, err := client.ValidateToken(ctx, req.Token) + if err != nil { + g.handleGRPCError(c, err) + return + } + + c.JSON(http.StatusOK, claims) +} + +func (g *Gateway) handleLogout(ctx context.Context, c *gin.Context, client services.AuthServiceClient) { + var req struct { + RefreshToken string `json:"refresh_token" binding:"required"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "Invalid request", + "details": err.Error(), + }) + return + } + + err := client.Logout(ctx, req.RefreshToken) + if err != nil { + g.handleGRPCError(c, err) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + }) +} + +// Identity Service Handlers + +func (g *Gateway) handleGetUser(ctx context.Context, c *gin.Context, client services.IdentityServiceClient, userID string) { + user, err := client.GetUser(ctx, userID) + if err != nil { + g.handleGRPCError(c, err) + return + } + + c.JSON(http.StatusOK, user) +} + +func (g *Gateway) handleGetUserByEmail(ctx context.Context, c *gin.Context, client services.IdentityServiceClient, email string) { + user, err := client.GetUserByEmail(ctx, email) + if err != nil { + g.handleGRPCError(c, err) + return + } + + c.JSON(http.StatusOK, user) +} + +func (g *Gateway) handleCreateUser(ctx context.Context, c *gin.Context, client services.IdentityServiceClient) { + var req services.CreateUserRequest + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "Invalid request", + "details": err.Error(), + }) + return + } + + user, err := client.CreateUser(ctx, &req) + if err != nil { + g.handleGRPCError(c, err) + return + } + + c.JSON(http.StatusCreated, user) +} + +func (g *Gateway) handleUpdateUser(ctx context.Context, c *gin.Context, client services.IdentityServiceClient, userID string) { + var req services.UpdateUserRequest + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "Invalid request", + "details": err.Error(), + }) + return + } + + user, err := client.UpdateUser(ctx, userID, &req) + if err != nil { + g.handleGRPCError(c, err) + return + } + + c.JSON(http.StatusOK, user) +} + +func (g *Gateway) handleDeleteUser(ctx context.Context, c *gin.Context, client services.IdentityServiceClient, userID string) { + err := client.DeleteUser(ctx, userID) + if err != nil { + g.handleGRPCError(c, err) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + }) +} + +func (g *Gateway) handleVerifyEmail(ctx context.Context, c *gin.Context, client services.IdentityServiceClient) { + var req struct { + Token string `json:"token" binding:"required"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "Invalid request", + "details": err.Error(), + }) + return + } + + err := client.VerifyEmail(ctx, req.Token) + if err != nil { + g.handleGRPCError(c, err) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + }) +} + +func (g *Gateway) handleRequestPasswordReset(ctx context.Context, c *gin.Context, client services.IdentityServiceClient) { + var req struct { + Email string `json:"email" binding:"required"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "Invalid request", + "details": err.Error(), + }) + return + } + + err := client.RequestPasswordReset(ctx, req.Email) + if err != nil { + g.handleGRPCError(c, err) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + }) +} + +func (g *Gateway) handleResetPassword(ctx context.Context, c *gin.Context, client services.IdentityServiceClient) { + var req struct { + Token string `json:"token" binding:"required"` + NewPassword string `json:"new_password" binding:"required"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "Invalid request", + "details": err.Error(), + }) + return + } + + err := client.ResetPassword(ctx, req.Token, req.NewPassword) + if err != nil { + g.handleGRPCError(c, err) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + }) +} + +// handleGRPCError converts gRPC errors to HTTP status codes and responses. +func (g *Gateway) handleGRPCError(c *gin.Context, err error) { + st, ok := status.FromError(err) + if !ok { + g.log.Error("Non-gRPC error from service", + logger.String("error", err.Error()), + ) + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "Internal server error", + }) + return + } + + var httpStatus int + var errorMsg string + + switch st.Code() { + case codes.Unauthenticated: + httpStatus = http.StatusUnauthorized + errorMsg = "Unauthorized" + case codes.PermissionDenied: + httpStatus = http.StatusForbidden + errorMsg = "Forbidden" + case codes.NotFound: + httpStatus = http.StatusNotFound + errorMsg = "Not found" + case codes.InvalidArgument: + httpStatus = http.StatusBadRequest + errorMsg = "Invalid request" + case codes.AlreadyExists: + httpStatus = http.StatusConflict + errorMsg = "Resource already exists" + case codes.Internal: + httpStatus = http.StatusInternalServerError + errorMsg = "Internal server error" + case codes.Unavailable: + httpStatus = http.StatusServiceUnavailable + errorMsg = "Service unavailable" + default: + httpStatus = http.StatusInternalServerError + errorMsg = "Internal server error" + } + + // Include gRPC error message if available + response := gin.H{ + "error": errorMsg, + } + if st.Message() != "" { + response["details"] = st.Message() + } + + c.JSON(httpStatus, response) +}