package api import ( "containr/internal/database" "context" "crypto/rand" "database/sql" "encoding/hex" "errors" "fmt" "net/http" "strings" "time" "github.com/gin-gonic/gin" "github.com/go-playground/validator/v10" "github.com/lib/pq" "golang.org/x/crypto/bcrypt" ) // Default quota limits applied when the request omits them. const ( DefaultRPMLimit = 60 DefaultMonthlyQuota = 1000 ) // Validator instance for request validation var validate = validator.New() // APIError represents a structured API error response type APIError struct { Code string `json:"code"` Message string `json:"message"` Details interface{} `json:"details,omitempty"` } // APIResponse represents a standardized API response type APIResponse struct { Success bool `json:"success"` Data interface{} `json:"data,omitempty"` Error *APIError `json:"error,omitempty"` Meta *Meta `json:"meta,omitempty"` } // Meta contains pagination and metadata type Meta struct { Page int `json:"page,omitempty"` PerPage int `json:"per_page,omitempty"` Total int `json:"total,omitempty"` TotalPages int `json:"total_pages,omitempty"` } // ServiceRequest represents the request payload for creating/updating services type ServiceRequest struct { Name string `json:"name" binding:"required,max=100" validate:"required,min=1,max=100"` UpstreamURL string `json:"upstreamUrl" binding:"required,max=500" validate:"required,url,max=500"` RoutePrefix string `json:"routePrefix" binding:"required,max=200" validate:"required,min=1,max=200"` Enabled *bool `json:"enabled,omitempty"` RPMLimit *int `json:"rpmLimit,omitempty" validate:"omitempty,min=1,max=10000"` MonthlyQuota *int `json:"monthlyQuota,omitempty" validate:"omitempty,min=1,max=10000000"` } // APIKeyRequest represents the request payload for creating/updating API keys type APIKeyRequest struct { Name string `json:"name" binding:"required,max=100" validate:"required,min=1,max=100"` Plan string `json:"plan" validate:"omitempty,oneof=free pro business enterprise"` Enabled *bool `json:"enabled,omitempty"` RPMLimit *int `json:"rpmLimit,omitempty" validate:"omitempty,min=1,max=10000"` MonthlyQuota *int `json:"monthlyQuota,omitempty" validate:"omitempty,min=1,max=10000000"` } // generateAPIKey generates a cryptographically secure API key func generateAPIKey() (string, error) { bytes := make([]byte, 32) if _, err := rand.Read(bytes); err != nil { return "", fmt.Errorf("failed to generate random bytes: %w", err) } return "ap_" + hex.EncodeToString(bytes), nil } // hashAPIKey creates a bcrypt hash for the API key func hashAPIKey(key string) (string, error) { hash, err := bcrypt.GenerateFromPassword([]byte(key), bcrypt.DefaultCost) if err != nil { return "", fmt.Errorf("failed to hash API key: %w", err) } return string(hash), nil } // validateAPIKeyPlan validates and returns default values for API key plans func validateAPIKeyPlan(plan string) (string, int, int, error) { if plan == "" { plan = "free" } switch plan { case "free": return plan, 60, 1000, nil case "pro": return plan, 600, 50000, nil case "business": return plan, 3000, 300000, nil case "enterprise": return plan, 10000, 10000000, nil default: return "", 0, 0, fmt.Errorf("invalid plan: %s", plan) } } // sendJSONResponse sends a standardized JSON response func sendJSONResponse(c *gin.Context, statusCode int, response APIResponse) { c.JSON(statusCode, response) } // sendErrorResponse sends a standardized error response func sendErrorResponse(c *gin.Context, statusCode int, code, message string, details interface{}) { response := APIResponse{ Success: false, Error: &APIError{ Code: code, Message: message, Details: details, }, } sendJSONResponse(c, statusCode, response) } // sendSuccessResponse sends a standardized success response func sendSuccessResponse(c *gin.Context, statusCode int, data interface{}) { response := APIResponse{ Success: true, Data: data, } sendJSONResponse(c, statusCode, response) } // validateRequest validates the request payload using the validator func validateRequest(c *gin.Context, req interface{}) error { if err := c.ShouldBindJSON(req); err != nil { return fmt.Errorf("invalid request body: %w", err) } if err := validate.Struct(req); err != nil { return fmt.Errorf("validation failed: %w", err) } return nil } // isUniqueViolation reports whether err is a Postgres unique-constraint violation. func isUniqueViolation(err error) bool { var pqErr *pq.Error return errors.As(err, &pqErr) && pqErr.Code == "23505" } // handleAPwhyServiceValidate probes the upstream health path and records the // result on the service row. Ported from the standalone APwhy server. func handleAPwhyServiceValidate(c *gin.Context) { serviceID := c.Param("id") db := c.MustGet("db").(*database.DB) ctx := c.Request.Context() var upstreamURL, healthPath string var authHeader, authValue sql.NullString var timeoutMs sql.NullInt64 err := db.QueryRowContext(ctx, ` SELECT upstream_url, health_path, upstream_auth_header, upstream_auth_value, request_timeout_ms FROM api_services WHERE id = $1 `, serviceID).Scan(&upstreamURL, &healthPath, &authHeader, &authValue, &timeoutMs) if err != nil { sendErrorResponse(c, http.StatusNotFound, "SERVICE_NOT_FOUND", "Service not found", nil) return } timeout := 8000 if timeoutMs.Valid && timeoutMs.Int64 > 0 { timeout = int(timeoutMs.Int64) } target := strings.TrimRight(upstreamURL, "/") + "/" + strings.TrimLeft(healthPath, "/") req, _ := http.NewRequestWithContext(ctx, http.MethodGet, target, nil) if authHeader.Valid && authHeader.String != "" { req.Header.Set(authHeader.String, authValue.String) } client := &http.Client{Timeout: time.Duration(timeout) * time.Millisecond} res, probeErr := client.Do(req) status := http.StatusBadGateway message := "" ok := false if probeErr != nil { message = probeErr.Error() } else { defer res.Body.Close() status = res.StatusCode message = fmt.Sprintf("HTTP %d", res.StatusCode) ok = res.StatusCode >= 200 && res.StatusCode < 300 } validationStatus := "failed" if ok { validationStatus = "healthy" } _, _ = db.ExecContext(ctx, ` UPDATE api_services SET last_validation_at = NOW(), last_validation_status = $1, last_validation_message = $2, updated_at = NOW() WHERE id = $3 `, validationStatus, message, serviceID) if !ok { _, _ = db.ExecContext(ctx, ` INSERT INTO incident_events (service_id, code, message, severity, http_status, occurred_at) VALUES ($1, 'SERVICE_VALIDATION_FAILED', $2, 'medium', $3, NOW()) `, serviceID, message, status) } sendSuccessResponse(c, http.StatusOK, map[string]interface{}{ "validation": map[string]interface{}{ "ok": ok, "status": status, "message": message, }, }) } // handleAPwhyServicesList returns a list of API services func handleAPwhyServicesList(c *gin.Context) { db := c.MustGet("db").(*database.DB) ctx := context.Background() // Query services from database rows, err := db.QueryContext(ctx, ` SELECT id, name, slug, upstream_url, route_prefix, enabled, created_at, updated_at FROM api_services ORDER BY created_at DESC `) if err != nil { sendErrorResponse(c, http.StatusInternalServerError, "DATABASE_ERROR", "Failed to query services", err.Error()) return } defer rows.Close() services := make([]map[string]interface{}, 0) for rows.Next() { var id, name, slug, upstreamURL, routePrefix, createdAt, updatedAt string var enabled int err := rows.Scan(&id, &name, &slug, &upstreamURL, &routePrefix, &enabled, &createdAt, &updatedAt) if err != nil { continue // Skip malformed rows } services = append(services, map[string]interface{}{ "id": id, "name": name, "slug": slug, "upstreamUrl": upstreamURL, "routePrefix": routePrefix, "enabled": enabled != 0, "createdAt": createdAt, "updatedAt": updatedAt, }) } sendSuccessResponse(c, http.StatusOK, map[string]interface{}{ "services": services, "count": len(services), }) } // handleAPwhyServicesCreate creates a new API service func handleAPwhyServicesCreate(c *gin.Context) { var req ServiceRequest if err := validateRequest(c, &req); err != nil { sendErrorResponse(c, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid request parameters", err.Error()) return } db := c.MustGet("db").(*database.DB) ctx := context.Background() slug := strings.ToLower(strings.ReplaceAll(req.Name, " ", "-")) var rpmLimit, monthlyQuota int if req.RPMLimit != nil { rpmLimit = *req.RPMLimit } else { rpmLimit = DefaultRPMLimit } if req.MonthlyQuota != nil { monthlyQuota = *req.MonthlyQuota } else { monthlyQuota = DefaultMonthlyQuota } // id defaults to gen_random_uuid(); enabled defaults to 1. var id string err := db.QueryRowContext(ctx, ` INSERT INTO api_services ( name, slug, upstream_url, route_prefix, health_path, rpm_limit, monthly_quota, created_at, updated_at ) VALUES ($1, $2, $3, $4, '/health', $5, $6, NOW(), NOW()) RETURNING id `, req.Name, slug, req.UpstreamURL, req.RoutePrefix, rpmLimit, monthlyQuota).Scan(&id) if err != nil { if isUniqueViolation(err) { sendErrorResponse(c, http.StatusConflict, "CONFLICT", "A service with this slug or route prefix already exists", err.Error()) return } sendErrorResponse(c, http.StatusInternalServerError, "DATABASE_ERROR", "Failed to create service", err.Error()) return } serviceData := map[string]interface{}{ "id": id, "name": req.Name, "slug": slug, "upstreamUrl": req.UpstreamURL, "routePrefix": req.RoutePrefix, "enabled": true, "rpmLimit": rpmLimit, "monthlyQuota": monthlyQuota, "createdAt": time.Now().UTC().Format(time.RFC3339), } sendSuccessResponse(c, http.StatusCreated, map[string]interface{}{ "service": serviceData, "message": "Service created successfully", }) } // handleAPwhyServicesPatch updates an existing API service func handleAPwhyServicesPatch(c *gin.Context) { serviceID := c.Param("id") var input struct { Enabled *bool `json:"enabled"` } if err := c.ShouldBindJSON(&input); err != nil { c.JSON(http.StatusBadRequest, gin.H{ "ok": false, "error": "Invalid input: " + err.Error(), }) return } db := c.MustGet("db").(*database.DB) if input.Enabled != nil { enabled := 0 if *input.Enabled { enabled = 1 } _, err := db.ExecContext(context.Background(), "UPDATE api_services SET enabled = $1, updated_at = NOW() WHERE id = $2", enabled, serviceID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{ "ok": false, "error": "Failed to update service: " + err.Error(), }) return } } c.JSON(http.StatusOK, gin.H{ "ok": true, "data": gin.H{"id": serviceID, "updated": true}, }) } // handleAPwhyKeysList returns a list of API keys func handleAPwhyKeysList(c *gin.Context) { db := c.MustGet("db").(*database.DB) rows, err := db.QueryContext(context.Background(), ` SELECT id, name, key_prefix, plan, enabled, rpm_limit, monthly_quota, created_at, updated_at FROM api_keys ORDER BY created_at DESC `) if err != nil { c.JSON(http.StatusOK, gin.H{ "ok": true, "data": []interface{}{}, }) return } defer rows.Close() keys := make([]map[string]interface{}, 0) for rows.Next() { var id, name, keyPrefix, plan, createdAt, updatedAt string var enabled int var rpmLimit, monthlyQuota sql.NullInt64 err := rows.Scan(&id, &name, &keyPrefix, &plan, &enabled, &rpmLimit, &monthlyQuota, &createdAt, &updatedAt) if err != nil { continue } key := map[string]interface{}{ "id": id, "name": name, "keyPrefix": keyPrefix, "plan": plan, "enabled": enabled != 0, "createdAt": createdAt, "updatedAt": updatedAt, } if rpmLimit.Valid { key["rpmLimit"] = rpmLimit.Int64 } if monthlyQuota.Valid { key["monthlyQuota"] = monthlyQuota.Int64 } keys = append(keys, key) } c.JSON(http.StatusOK, gin.H{ "ok": true, "data": keys, }) } // handleAPwhyKeysCreate creates a new API key func handleAPwhyKeysCreate(c *gin.Context) { var input struct { Name string `json:"name" binding:"required"` Plan string `json:"plan"` } if err := c.ShouldBindJSON(&input); err != nil { c.JSON(http.StatusBadRequest, gin.H{ "ok": false, "error": "Invalid input: " + err.Error(), }) return } if input.Plan == "" { input.Plan = "free" } db := c.MustGet("db").(*database.DB) // Generate API key and hash apiKey, err := generateAPIKey() if err != nil { sendErrorResponse(c, http.StatusInternalServerError, "GENERATION_ERROR", "Failed to generate API key", err.Error()) return } keyHash, err := hashAPIKey(apiKey) if err != nil { sendErrorResponse(c, http.StatusInternalServerError, "HASH_ERROR", "Failed to hash API key", err.Error()) return } keyPrefix := apiKey[:8] // Set default limits based on plan plan, rpmLimit, monthlyQuota, err := validateAPIKeyPlan(input.Plan) if err != nil { sendErrorResponse(c, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid plan", err.Error()) return } // id defaults to gen_random_uuid(); enabled defaults to 1. var id string err = db.QueryRowContext(context.Background(), ` INSERT INTO api_keys ( name, key_hash, key_prefix, plan, allowed_service_ids, rpm_limit, monthly_quota, created_at, updated_at ) VALUES ($1, $2, $3, $4, '[]', $5, $6, NOW(), NOW()) RETURNING id `, input.Name, keyHash, keyPrefix, plan, rpmLimit, monthlyQuota).Scan(&id) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{ "ok": false, "error": "Failed to create key: " + err.Error(), }) return } c.JSON(http.StatusCreated, gin.H{ "ok": true, "data": gin.H{ "id": id, "name": input.Name, "plan": plan, "key": apiKey, // Only return the actual key once "keyPrefix": keyPrefix, "enabled": true, "rpmLimit": rpmLimit, "monthlyQuota": monthlyQuota, }, }) } // handleAPwhyKeysPatch updates an existing API key func handleAPwhyKeysPatch(c *gin.Context) { keyID := c.Param("id") var input struct { Enabled *bool `json:"enabled"` } if err := c.ShouldBindJSON(&input); err != nil { c.JSON(http.StatusBadRequest, gin.H{ "ok": false, "error": "Invalid input: " + err.Error(), }) return } db := c.MustGet("db").(*database.DB) if input.Enabled != nil { enabled := 0 if *input.Enabled { enabled = 1 } _, err := db.ExecContext(context.Background(), "UPDATE api_keys SET enabled = $1, updated_at = NOW() WHERE id = $2", enabled, keyID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{ "ok": false, "error": "Failed to update key: " + err.Error(), }) return } } c.JSON(http.StatusOK, gin.H{ "ok": true, "data": gin.H{"id": keyID, "updated": true}, }) } // handleAPwhyAnalyticsOps returns operational analytics func handleAPwhyAnalyticsOps(c *gin.Context) { db := c.MustGet("db").(*database.DB) // Get counts from database var totalServices, totalKeys, totalUsers int if err := db.QueryRowContext(context.Background(), "SELECT COUNT(*) FROM api_services").Scan(&totalServices); err != nil { c.JSON(http.StatusInternalServerError, gin.H{ "ok": false, "error": "Failed to count services: " + err.Error(), }) return } if err := db.QueryRowContext(context.Background(), "SELECT COUNT(*) FROM api_keys").Scan(&totalKeys); err != nil { c.JSON(http.StatusInternalServerError, gin.H{ "ok": false, "error": "Failed to count api keys: " + err.Error(), }) return } if err := db.QueryRowContext(context.Background(), "SELECT COUNT(*) FROM users").Scan(&totalUsers); err != nil { c.JSON(http.StatusInternalServerError, gin.H{ "ok": false, "error": "Failed to count users: " + err.Error(), }) return } var totalRequests int if err := db.QueryRowContext(context.Background(), "SELECT COALESCE(SUM(request_count), 0) FROM usage_counters").Scan(&totalRequests); err != nil { c.JSON(http.StatusInternalServerError, gin.H{ "ok": false, "error": "Failed to aggregate request counters: " + err.Error(), }) return } var requestsToday int if err := db.QueryRowContext(context.Background(), ` SELECT COALESCE(SUM(value), 0)::int FROM metrics_timeseries WHERE metric = 'request_total' AND occurred_at >= DATE_TRUNC('day', NOW()) `).Scan(&requestsToday); err != nil { c.JSON(http.StatusInternalServerError, gin.H{ "ok": false, "error": "Failed to aggregate today's requests: " + err.Error(), }) return } var requestsThisMonth int if err := db.QueryRowContext(context.Background(), ` SELECT COALESCE(SUM(value), 0)::int FROM metrics_timeseries WHERE metric = 'request_total' AND occurred_at >= DATE_TRUNC('month', NOW()) `).Scan(&requestsThisMonth); err != nil { c.JSON(http.StatusInternalServerError, gin.H{ "ok": false, "error": "Failed to aggregate monthly requests: " + err.Error(), }) return } c.JSON(http.StatusOK, gin.H{ "ok": true, "data": gin.H{ "total_requests": totalRequests, "total_services": totalServices, "total_keys": totalKeys, "total_users": totalUsers, "requests_today": requestsToday, "requests_this_month": requestsThisMonth, }, }) } // handleAPwhyAnalyticsTraffic returns traffic analytics func handleAPwhyAnalyticsTraffic(c *gin.Context) { db := c.MustGet("db").(*database.DB) topServices := make([]map[string]interface{}, 0) serviceRows, err := db.QueryContext(context.Background(), ` SELECT s.id, s.name, COALESCE(SUM(u.request_count), 0) AS total_requests FROM api_services s LEFT JOIN usage_counters u ON u.service_id = s.id GROUP BY s.id, s.name ORDER BY total_requests DESC, s.name ASC LIMIT 10 `) if err == nil { defer serviceRows.Close() for serviceRows.Next() { var serviceID, serviceName string var totalRequests int if scanErr := serviceRows.Scan(&serviceID, &serviceName, &totalRequests); scanErr == nil { topServices = append(topServices, map[string]interface{}{ "service_id": serviceID, "name": serviceName, "requests": totalRequests, }) } } } requestsByDay := make([]map[string]interface{}, 0) trafficRows, err := db.QueryContext(context.Background(), ` SELECT TO_CHAR(DATE_TRUNC('day', occurred_at), 'YYYY-MM-DD') AS day_bucket, COUNT(*) AS total FROM metrics_timeseries WHERE metric = 'request_total' AND occurred_at >= NOW() - INTERVAL '7 days' GROUP BY DATE_TRUNC('day', occurred_at) ORDER BY DATE_TRUNC('day', occurred_at) ASC `) if err == nil { defer trafficRows.Close() for trafficRows.Next() { var day string var count int if scanErr := trafficRows.Scan(&day, &count); scanErr == nil { requestsByDay = append(requestsByDay, map[string]interface{}{ "day": day, "requests": count, }) } } } statusCodes := make([]map[string]interface{}, 0) statusRows, err := db.QueryContext(context.Background(), ` SELECT COALESCE(http_status, 0) AS status_code, COALESCE(SUM(count), 0) AS total FROM incident_events WHERE occurred_at >= NOW() - INTERVAL '7 days' GROUP BY COALESCE(http_status, 0) ORDER BY total DESC, status_code ASC `) if err == nil { defer statusRows.Close() for statusRows.Next() { var code int var total int if scanErr := statusRows.Scan(&code, &total); scanErr == nil { statusCodes = append(statusCodes, map[string]interface{}{ "status_code": code, "count": total, }) } } } clientEvents := make([]map[string]interface{}, 0) eventRows, err := db.QueryContext(context.Background(), ` SELECT COALESCE((labels_json::jsonb ->> 'path'), 'unknown') AS path, COUNT(*) AS total FROM metrics_timeseries WHERE metric = 'client_event' AND occurred_at >= NOW() - INTERVAL '7 days' GROUP BY COALESCE((labels_json::jsonb ->> 'path'), 'unknown') ORDER BY total DESC, path ASC LIMIT 20 `) if err == nil { defer eventRows.Close() for eventRows.Next() { var path string var total int if scanErr := eventRows.Scan(&path, &total); scanErr == nil { clientEvents = append(clientEvents, map[string]interface{}{ "path": path, "count": total, }) } } } c.JSON(http.StatusOK, gin.H{ "ok": true, "data": gin.H{ "top_services": topServices, "requests_by_day": requestsByDay, "status_codes": statusCodes, "client_events": clientEvents, }, }) }