fix(consul): fix gRPC health checks and add API Gateway Consul registration

This commit is contained in:
2025-11-06 22:04:55 +01:00
parent 04022b835e
commit dbe29bfb82
8 changed files with 610 additions and 66 deletions

View File

@@ -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
}

View File

@@ -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)
}