Files
Containr/app/backend/internal/api/apwhy_handlers.go
T
Tomas Dvorak 9e3aa3aa17 Consolidate backend: drop vendored APwhy, converge on goose + sqlc
Phase 0 cleanup verified end-to-end:

- Delete vendored APwhy runtime (cmd/apwhy, internal/apwhy) and the
  unwired copy (internal/api/server.go + static bundle + cloned
  internal/{storage,analytics,auth,rbac}) — ~7.5k lines of dead code.
- Keep Containr's native internal/gateway; move APwhy management
  routes under /api/v1/gateway/* so they stop colliding with the
  native /services paths. Fix handlers against the real schema:
  UUID ids via RETURNING, integer enabled flags, plan validation,
  service validate endpoint, unique-violation mapping.
- Port agents.go off GORM onto sqlc (sqlc/queries/agents.sql);
  drop gorm dependencies; fix sqlc schema drift (services.environment_id).
- Replace the SELECT 1 goose baseline with the real consolidated
  schema (52 tables) minus APwhy's parallel auth/RBAC objects;
  idempotent via IF NOT EXISTS / OR REPLACE / guarded DO blocks so it
  replays cleanly over legacy installs. Add a drop migration for the
  dead objects and delete the legacy filename-tracked migrations/
  loader, directory, and Dockerfile copy. Verified via cmd/migrate:
  fresh install 53 tables, legacy upgrade clean to 20260917000000.
- Canvas: add invisible React Flow handles to ServiceNode so inferred
  auto-connections stop emitting warning #008.
- Remove reactflow dep (superseded by @xyflow/react), test harness
  cmds, and legacy 0001_init.sql backups.

Authored By: TDvorak <[email protected]>
2026-09-17 09:40:08 +02:00

740 lines
20 KiB
Go

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