Compare commits
2 Commits
8f30fe7412
...
70bfb54acf
| Author | SHA1 | Date | |
|---|---|---|---|
| 70bfb54acf | |||
| a3ed6685de |
@@ -6,6 +6,7 @@ import (
|
||||
"time"
|
||||
|
||||
"goyco/internal/config"
|
||||
"goyco/internal/health"
|
||||
"goyco/internal/middleware"
|
||||
"goyco/internal/repositories"
|
||||
"goyco/internal/services"
|
||||
@@ -21,7 +22,7 @@ type APIHandler struct {
|
||||
userRepo repositories.UserRepository
|
||||
voteService *services.VoteService
|
||||
dbMonitor middleware.DBMonitor
|
||||
healthChecker *middleware.DatabaseHealthChecker
|
||||
healthChecker *health.CompositeChecker
|
||||
metricsCollector *middleware.MetricsCollector
|
||||
}
|
||||
|
||||
@@ -44,7 +45,21 @@ func NewAPIHandlerWithMonitoring(config *config.Config, postRepo repositories.Po
|
||||
return NewAPIHandler(config, postRepo, userRepo, voteService)
|
||||
}
|
||||
|
||||
healthChecker := middleware.NewDatabaseHealthChecker(sqlDB, dbMonitor)
|
||||
compositeChecker := health.NewCompositeChecker()
|
||||
|
||||
dbChecker := health.NewDatabaseChecker(sqlDB, dbMonitor)
|
||||
compositeChecker.AddChecker(dbChecker)
|
||||
|
||||
smtpConfig := health.SMTPConfig{
|
||||
Host: config.SMTP.Host,
|
||||
Port: config.SMTP.Port,
|
||||
Username: config.SMTP.Username,
|
||||
Password: config.SMTP.Password,
|
||||
From: config.SMTP.From,
|
||||
}
|
||||
smtpChecker := health.NewSMTPChecker(smtpConfig)
|
||||
compositeChecker.AddChecker(smtpChecker)
|
||||
|
||||
metricsCollector := middleware.NewMetricsCollector(dbMonitor)
|
||||
|
||||
return &APIHandler{
|
||||
@@ -53,7 +68,7 @@ func NewAPIHandlerWithMonitoring(config *config.Config, postRepo repositories.Po
|
||||
userRepo: userRepo,
|
||||
voteService: voteService,
|
||||
dbMonitor: dbMonitor,
|
||||
healthChecker: healthChecker,
|
||||
healthChecker: compositeChecker,
|
||||
metricsCollector: metricsCollector,
|
||||
}
|
||||
}
|
||||
@@ -135,17 +150,17 @@ func (h *APIHandler) GetAPIInfo(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// @Summary Health check
|
||||
// @Description Check the API health status along with database connectivity details
|
||||
// @Description Check the API health status along with database connectivity and SMTP service details
|
||||
// @Tags api
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {object} CommonResponse "Health check successful"
|
||||
// @Router /health [get]
|
||||
func (h *APIHandler) GetHealth(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
if h.healthChecker != nil {
|
||||
health := h.healthChecker.CheckHealth()
|
||||
health["version"] = version.GetVersion()
|
||||
health := h.healthChecker.CheckWithVersion(ctx, version.GetVersion())
|
||||
SendSuccessResponse(w, "Health check successful", health)
|
||||
return
|
||||
}
|
||||
|
||||
54
internal/health/composite.go
Normal file
54
internal/health/composite.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package health
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type CompositeChecker struct {
|
||||
checkers []Checker
|
||||
}
|
||||
|
||||
func NewCompositeChecker(checkers ...Checker) *CompositeChecker {
|
||||
return &CompositeChecker{
|
||||
checkers: checkers,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *CompositeChecker) AddChecker(checker Checker) {
|
||||
c.checkers = append(c.checkers, checker)
|
||||
}
|
||||
|
||||
func (c *CompositeChecker) Check(ctx context.Context) OverallResult {
|
||||
results := make(map[string]Result)
|
||||
var mu sync.Mutex
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for _, checker := range c.checkers {
|
||||
wg.Add(1)
|
||||
go func(ch Checker) {
|
||||
defer wg.Done()
|
||||
|
||||
result := ch.Check(ctx)
|
||||
|
||||
mu.Lock()
|
||||
results[ch.Name()] = result
|
||||
mu.Unlock()
|
||||
}(checker)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
return OverallResult{
|
||||
Status: determineOverallStatus(results),
|
||||
Timestamp: time.Now().UTC(),
|
||||
Services: results,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *CompositeChecker) CheckWithVersion(ctx context.Context, version string) OverallResult {
|
||||
result := c.Check(ctx)
|
||||
result.Version = version
|
||||
return result
|
||||
}
|
||||
61
internal/health/database.go
Normal file
61
internal/health/database.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package health
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"time"
|
||||
|
||||
"goyco/internal/middleware"
|
||||
)
|
||||
|
||||
type DatabaseChecker struct {
|
||||
db *sql.DB
|
||||
monitor middleware.DBMonitor
|
||||
}
|
||||
|
||||
func NewDatabaseChecker(db *sql.DB, monitor middleware.DBMonitor) *DatabaseChecker {
|
||||
return &DatabaseChecker{
|
||||
db: db,
|
||||
monitor: monitor,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *DatabaseChecker) Name() string {
|
||||
return "database"
|
||||
}
|
||||
|
||||
func (c *DatabaseChecker) Check(ctx context.Context) Result {
|
||||
start := time.Now()
|
||||
|
||||
err := c.db.Ping()
|
||||
latency := time.Since(start)
|
||||
|
||||
result := Result{
|
||||
Status: StatusHealthy,
|
||||
Latency: latency,
|
||||
Timestamp: time.Now().UTC(),
|
||||
Details: map[string]any{
|
||||
"ping_time": latency.String(),
|
||||
},
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
result.Status = StatusUnhealthy
|
||||
result.Message = err.Error()
|
||||
return result
|
||||
}
|
||||
|
||||
if c.monitor != nil {
|
||||
stats := c.monitor.GetStats()
|
||||
result.Details["stats"] = map[string]any{
|
||||
"total_queries": stats.TotalQueries,
|
||||
"slow_queries": stats.SlowQueries,
|
||||
"average_duration": stats.AverageDuration.String(),
|
||||
"max_duration": stats.MaxDuration.String(),
|
||||
"error_count": stats.ErrorCount,
|
||||
"last_query_time": stats.LastQueryTime.Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
56
internal/health/health.go
Normal file
56
internal/health/health.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package health
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Status string
|
||||
|
||||
const (
|
||||
StatusHealthy Status = "healthy"
|
||||
StatusDegraded Status = "degraded"
|
||||
StatusUnhealthy Status = "unhealthy"
|
||||
)
|
||||
|
||||
type Result struct {
|
||||
Status Status `json:"status"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Latency time.Duration `json:"latency"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Details map[string]any `json:"details,omitempty"`
|
||||
}
|
||||
|
||||
type Checker interface {
|
||||
Name() string
|
||||
Check(ctx context.Context) Result
|
||||
}
|
||||
|
||||
type OverallResult struct {
|
||||
Status Status `json:"status"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Version string `json:"version"`
|
||||
Services map[string]Result `json:"services"`
|
||||
}
|
||||
|
||||
func determineOverallStatus(results map[string]Result) Status {
|
||||
hasUnhealthy := false
|
||||
hasDegraded := false
|
||||
|
||||
for _, result := range results {
|
||||
switch result.Status {
|
||||
case StatusUnhealthy:
|
||||
hasUnhealthy = true
|
||||
case StatusDegraded:
|
||||
hasDegraded = true
|
||||
}
|
||||
}
|
||||
|
||||
if hasUnhealthy {
|
||||
return StatusUnhealthy
|
||||
}
|
||||
if hasDegraded {
|
||||
return StatusDegraded
|
||||
}
|
||||
return StatusHealthy
|
||||
}
|
||||
295
internal/health/health_test.go
Normal file
295
internal/health/health_test.go
Normal file
@@ -0,0 +1,295 @@
|
||||
package health
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
"goyco/internal/middleware"
|
||||
)
|
||||
|
||||
// MockDBMonitor is a mock implementation of DBMonitor for testing
|
||||
type MockDBMonitor struct {
|
||||
stats middleware.DBStats
|
||||
}
|
||||
|
||||
func (m *MockDBMonitor) GetStats() middleware.DBStats {
|
||||
return m.stats
|
||||
}
|
||||
|
||||
func (m *MockDBMonitor) LogQuery(query string, duration time.Duration, err error) {
|
||||
// Mock implementation - does nothing
|
||||
}
|
||||
|
||||
func (m *MockDBMonitor) LogSlowQuery(query string, duration time.Duration, threshold time.Duration) {
|
||||
// Mock implementation - does nothing
|
||||
}
|
||||
|
||||
func TestStatusConstants(t *testing.T) {
|
||||
if StatusHealthy != "healthy" {
|
||||
t.Errorf("Expected StatusHealthy to be 'healthy', got %s", StatusHealthy)
|
||||
}
|
||||
if StatusDegraded != "degraded" {
|
||||
t.Errorf("Expected StatusDegraded to be 'degraded', got %s", StatusDegraded)
|
||||
}
|
||||
if StatusUnhealthy != "unhealthy" {
|
||||
t.Errorf("Expected StatusUnhealthy to be 'unhealthy', got %s", StatusUnhealthy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetermineOverallStatus(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
results map[string]Result
|
||||
expected Status
|
||||
}{
|
||||
{
|
||||
name: "all healthy",
|
||||
results: map[string]Result{"db": {Status: StatusHealthy}, "smtp": {Status: StatusHealthy}},
|
||||
expected: StatusHealthy,
|
||||
},
|
||||
{
|
||||
name: "one degraded",
|
||||
results: map[string]Result{"db": {Status: StatusHealthy}, "smtp": {Status: StatusDegraded}},
|
||||
expected: StatusDegraded,
|
||||
},
|
||||
{
|
||||
name: "one unhealthy",
|
||||
results: map[string]Result{"db": {Status: StatusUnhealthy}, "smtp": {Status: StatusHealthy}},
|
||||
expected: StatusUnhealthy,
|
||||
},
|
||||
{
|
||||
name: "mixed degraded and unhealthy",
|
||||
results: map[string]Result{"db": {Status: StatusDegraded}, "smtp": {Status: StatusUnhealthy}},
|
||||
expected: StatusUnhealthy,
|
||||
},
|
||||
{
|
||||
name: "empty results",
|
||||
results: map[string]Result{},
|
||||
expected: StatusHealthy,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := determineOverallStatus(tt.results)
|
||||
if result != tt.expected {
|
||||
t.Errorf("Expected %s, got %s", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatabaseChecker_Name(t *testing.T) {
|
||||
checker := &DatabaseChecker{}
|
||||
if checker.Name() != "database" {
|
||||
t.Errorf("Expected name 'database', got %s", checker.Name())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatabaseChecker_Check(t *testing.T) {
|
||||
// Create an in-memory database using the default driver
|
||||
db, err := sql.Open("sqlite", ":memory:")
|
||||
if err != nil {
|
||||
// Fallback: create a nil db and just verify the checker structure
|
||||
t.Logf("Could not open test database: %v", err)
|
||||
t.Skip("Skipping database-dependent test")
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
monitor := &MockDBMonitor{
|
||||
stats: middleware.DBStats{
|
||||
TotalQueries: 10,
|
||||
SlowQueries: 1,
|
||||
AverageDuration: 5 * time.Millisecond,
|
||||
MaxDuration: 20 * time.Millisecond,
|
||||
ErrorCount: 0,
|
||||
LastQueryTime: time.Now(),
|
||||
},
|
||||
}
|
||||
|
||||
checker := NewDatabaseChecker(db, monitor)
|
||||
ctx := context.Background()
|
||||
|
||||
result := checker.Check(ctx)
|
||||
|
||||
// Note: The actual result depends on database availability
|
||||
// We just verify the structure is correct
|
||||
if result.Timestamp.IsZero() {
|
||||
t.Error("Expected non-zero timestamp")
|
||||
}
|
||||
|
||||
if _, ok := result.Details["ping_time"]; !ok {
|
||||
t.Error("Expected ping_time in details")
|
||||
}
|
||||
|
||||
// Only check stats if status is healthy
|
||||
if result.Status == StatusHealthy {
|
||||
stats, ok := result.Details["stats"].(map[string]any)
|
||||
if !ok {
|
||||
t.Log("Stats not available in details (may be expected)")
|
||||
} else {
|
||||
if stats["total_queries"] != int64(10) {
|
||||
t.Errorf("Expected total_queries to be 10, got %v", stats["total_queries"])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatabaseChecker_Check_Unhealthy(t *testing.T) {
|
||||
// Test with a nil database connection
|
||||
checker := NewDatabaseChecker(nil, nil)
|
||||
ctx := context.Background()
|
||||
|
||||
// This should panic or return an error result
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Logf("Got expected panic with nil db: %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
result := checker.Check(ctx)
|
||||
|
||||
// If no panic, we should get an unhealthy status
|
||||
if result.Status != StatusUnhealthy {
|
||||
t.Logf("Expected unhealthy status for nil db, got %s", result.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSMTPChecker_Name(t *testing.T) {
|
||||
checker := &SMTPChecker{}
|
||||
if checker.Name() != "smtp" {
|
||||
t.Errorf("Expected name 'smtp', got %s", checker.Name())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSMTPChecker_Check_InvalidHost(t *testing.T) {
|
||||
config := SMTPConfig{
|
||||
Host: "invalid.host.that.does.not.exist",
|
||||
Port: 587,
|
||||
}
|
||||
|
||||
checker := NewSMTPChecker(config)
|
||||
ctx := context.Background()
|
||||
|
||||
result := checker.Check(ctx)
|
||||
|
||||
if result.Status != StatusDegraded {
|
||||
t.Errorf("Expected degraded status for invalid host, got %s", result.Status)
|
||||
}
|
||||
|
||||
if result.Message == "" {
|
||||
t.Error("Expected error message for connection failure")
|
||||
}
|
||||
|
||||
if result.Details["host"] != "invalid.host.that.does.not.exist:587" {
|
||||
t.Errorf("Expected host in details, got %v", result.Details["host"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSMTPChecker_Check_WithAuth(t *testing.T) {
|
||||
config := SMTPConfig{
|
||||
Host: "smtp.example.com",
|
||||
Port: 587,
|
||||
Username: "test@example.com",
|
||||
Password: "password",
|
||||
From: "noreply@example.com",
|
||||
}
|
||||
|
||||
checker := NewSMTPChecker(config)
|
||||
|
||||
// Just verify the configuration is stored correctly
|
||||
if checker.config.Host != "smtp.example.com" {
|
||||
t.Errorf("Expected host to be 'smtp.example.com', got %s", checker.config.Host)
|
||||
}
|
||||
if checker.config.Port != 587 {
|
||||
t.Errorf("Expected port to be 587, got %d", checker.config.Port)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompositeChecker(t *testing.T) {
|
||||
// Create a composite checker with test checkers
|
||||
checker1 := &mockChecker{
|
||||
name: "service1",
|
||||
status: StatusHealthy,
|
||||
}
|
||||
checker2 := &mockChecker{
|
||||
name: "service2",
|
||||
status: StatusHealthy,
|
||||
}
|
||||
|
||||
composite := NewCompositeChecker(checker1, checker2)
|
||||
ctx := context.Background()
|
||||
|
||||
result := composite.Check(ctx)
|
||||
|
||||
if result.Status != StatusHealthy {
|
||||
t.Errorf("Expected overall healthy status, got %s", result.Status)
|
||||
}
|
||||
|
||||
if len(result.Services) != 2 {
|
||||
t.Errorf("Expected 2 service results, got %d", len(result.Services))
|
||||
}
|
||||
|
||||
if _, ok := result.Services["service1"]; !ok {
|
||||
t.Error("Expected service1 in results")
|
||||
}
|
||||
|
||||
if _, ok := result.Services["service2"]; !ok {
|
||||
t.Error("Expected service2 in results")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompositeChecker_AddChecker(t *testing.T) {
|
||||
composite := NewCompositeChecker()
|
||||
|
||||
checker := &mockChecker{
|
||||
name: "test-service",
|
||||
status: StatusHealthy,
|
||||
}
|
||||
|
||||
composite.AddChecker(checker)
|
||||
|
||||
ctx := context.Background()
|
||||
result := composite.Check(ctx)
|
||||
|
||||
if len(result.Services) != 1 {
|
||||
t.Errorf("Expected 1 service result, got %d", len(result.Services))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompositeChecker_CheckWithVersion(t *testing.T) {
|
||||
checker := &mockChecker{
|
||||
name: "test",
|
||||
status: StatusHealthy,
|
||||
}
|
||||
|
||||
composite := NewCompositeChecker(checker)
|
||||
ctx := context.Background()
|
||||
|
||||
result := composite.CheckWithVersion(ctx, "v1.2.3")
|
||||
|
||||
if result.Version != "v1.2.3" {
|
||||
t.Errorf("Expected version 'v1.2.3', got %s", result.Version)
|
||||
}
|
||||
}
|
||||
|
||||
// mockChecker is a test implementation of the Checker interface
|
||||
type mockChecker struct {
|
||||
name string
|
||||
status Status
|
||||
}
|
||||
|
||||
func (m *mockChecker) Name() string {
|
||||
return m.name
|
||||
}
|
||||
|
||||
func (m *mockChecker) Check(ctx context.Context) Result {
|
||||
return Result{
|
||||
Status: m.status,
|
||||
Timestamp: time.Now().UTC(),
|
||||
Latency: 1 * time.Millisecond,
|
||||
}
|
||||
}
|
||||
106
internal/health/smtp.go
Normal file
106
internal/health/smtp.go
Normal file
@@ -0,0 +1,106 @@
|
||||
package health
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/smtp"
|
||||
"time"
|
||||
)
|
||||
|
||||
type SMTPConfig struct {
|
||||
Host string
|
||||
Port int
|
||||
Username string
|
||||
Password string
|
||||
From string
|
||||
}
|
||||
|
||||
type SMTPChecker struct {
|
||||
config SMTPConfig
|
||||
}
|
||||
|
||||
func NewSMTPChecker(config SMTPConfig) *SMTPChecker {
|
||||
return &SMTPChecker{
|
||||
config: config,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *SMTPChecker) Name() string {
|
||||
return "smtp"
|
||||
}
|
||||
|
||||
func (c *SMTPChecker) Check(ctx context.Context) Result {
|
||||
start := time.Now()
|
||||
address := fmt.Sprintf("%s:%d", c.config.Host, c.config.Port)
|
||||
|
||||
result := Result{
|
||||
Status: StatusHealthy,
|
||||
Timestamp: time.Now().UTC(),
|
||||
Details: map[string]any{
|
||||
"host": address,
|
||||
},
|
||||
}
|
||||
|
||||
conn, err := net.Dial("tcp", address)
|
||||
if err != nil {
|
||||
result.Status = StatusDegraded
|
||||
result.Message = fmt.Sprintf("Failed to connect to SMTP server: %v", err)
|
||||
result.Latency = time.Since(start)
|
||||
return result
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
client, err := smtp.NewClient(conn, c.config.Host)
|
||||
if err != nil {
|
||||
result.Status = StatusDegraded
|
||||
result.Message = fmt.Sprintf("Failed to create SMTP client: %v", err)
|
||||
result.Latency = time.Since(start)
|
||||
return result
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
err = client.Hello("goyco-health-check")
|
||||
if err != nil {
|
||||
result.Status = StatusDegraded
|
||||
result.Message = fmt.Sprintf("EHLO failed: %v", err)
|
||||
result.Latency = time.Since(start)
|
||||
return result
|
||||
}
|
||||
|
||||
if ok, _ := client.Extension("STARTTLS"); ok {
|
||||
tlsConfig := &tls.Config{
|
||||
ServerName: c.config.Host,
|
||||
}
|
||||
err = client.StartTLS(tlsConfig)
|
||||
if err != nil {
|
||||
result.Details["starttls"] = "failed"
|
||||
result.Details["starttls_error"] = err.Error()
|
||||
} else {
|
||||
result.Details["starttls"] = "enabled"
|
||||
}
|
||||
} else {
|
||||
result.Details["starttls"] = "not supported"
|
||||
}
|
||||
|
||||
if c.config.Username != "" && c.config.Password != "" {
|
||||
auth := smtp.PlainAuth("", c.config.Username, c.config.Password, c.config.Host)
|
||||
err = client.Auth(auth)
|
||||
if err != nil {
|
||||
result.Details["auth"] = "failed"
|
||||
result.Details["auth_error"] = err.Error()
|
||||
} else {
|
||||
result.Details["auth"] = "success"
|
||||
}
|
||||
} else {
|
||||
result.Details["auth"] = "not configured"
|
||||
}
|
||||
|
||||
client.Quit()
|
||||
|
||||
result.Latency = time.Since(start)
|
||||
result.Details["handshake"] = "completed"
|
||||
|
||||
return result
|
||||
}
|
||||
Reference in New Issue
Block a user