feat: first

This commit is contained in:
2025-08-13 19:36:09 +02:00
parent fd2ba2e999
commit 5fc4d1d997
17 changed files with 2622 additions and 111 deletions

100
internal/config/config.go Normal file
View File

@@ -0,0 +1,100 @@
package config
import (
"fmt"
"os"
"strconv"
"strings"
)
// Config holds all configuration for the application
type Config struct {
Server ServerConfig
Log LogConfig
Metrics MetricsConfig
}
// ServerConfig holds server-related configuration
type ServerConfig struct {
GRPCPort string
HTTPPort string
}
// LogConfig holds logging configuration
type LogConfig struct {
Level string
Format string
}
// MetricsConfig holds metrics configuration
type MetricsConfig struct {
Enabled bool
}
// Load loads configuration from environment variables with defaults
func Load() *Config {
return &Config{
Server: ServerConfig{
GRPCPort: getEnv("SERVER_GRPC_PORT", "8080"),
HTTPPort: getEnv("SERVER_HTTP_PORT", "8090"),
},
Log: LogConfig{
Level: strings.ToLower(getEnv("LOG_LEVEL", "info")),
Format: strings.ToLower(getEnv("LOG_FORMAT", "json")),
},
Metrics: MetricsConfig{
Enabled: getEnvBool("METRICS_ENABLED", true),
},
}
}
// Validate validates the configuration
func (c *Config) Validate() error {
if c.Server.GRPCPort == "" {
return fmt.Errorf("gRPC port cannot be empty")
}
if c.Server.HTTPPort == "" {
return fmt.Errorf("HTTP port cannot be empty")
}
if c.Server.GRPCPort == c.Server.HTTPPort {
return fmt.Errorf("gRPC and HTTP ports cannot be the same")
}
validLogLevels := map[string]bool{
"debug": true,
"info": true,
"warn": true,
"error": true,
}
if !validLogLevels[c.Log.Level] {
return fmt.Errorf("invalid log level: %s", c.Log.Level)
}
validLogFormats := map[string]bool{
"json": true,
"text": true,
}
if !validLogFormats[c.Log.Format] {
return fmt.Errorf("invalid log format: %s", c.Log.Format)
}
return nil
}
// getEnv gets an environment variable or returns a default value
func getEnv(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
}
return defaultValue
}
// getEnvBool gets a boolean environment variable or returns a default value
func getEnvBool(key string, defaultValue bool) bool {
if value := os.Getenv(key); value != "" {
if parsed, err := strconv.ParseBool(value); err == nil {
return parsed
}
}
return defaultValue
}

View File

@@ -0,0 +1,193 @@
package config
import (
"os"
"testing"
)
func TestLoad(t *testing.T) {
// Save original env vars
originalGRPCPort := os.Getenv("SERVER_GRPC_PORT")
originalHTTPPort := os.Getenv("SERVER_HTTP_PORT")
originalLogLevel := os.Getenv("LOG_LEVEL")
originalLogFormat := os.Getenv("LOG_FORMAT")
originalMetricsEnabled := os.Getenv("METRICS_ENABLED")
// Clean up after test
defer func() {
os.Setenv("SERVER_GRPC_PORT", originalGRPCPort)
os.Setenv("SERVER_HTTP_PORT", originalHTTPPort)
os.Setenv("LOG_LEVEL", originalLogLevel)
os.Setenv("LOG_FORMAT", originalLogFormat)
os.Setenv("METRICS_ENABLED", originalMetricsEnabled)
}()
tests := []struct {
name string
envVars map[string]string
expected Config
}{
{
name: "default values",
envVars: map[string]string{},
expected: Config{
Server: ServerConfig{
GRPCPort: "8080",
HTTPPort: "8090",
},
Log: LogConfig{
Level: "info",
Format: "json",
},
Metrics: MetricsConfig{
Enabled: true,
},
},
},
{
name: "custom values",
envVars: map[string]string{
"SERVER_GRPC_PORT": "9090",
"SERVER_HTTP_PORT": "9091",
"LOG_LEVEL": "debug",
"LOG_FORMAT": "text",
"METRICS_ENABLED": "false",
},
expected: Config{
Server: ServerConfig{
GRPCPort: "9090",
HTTPPort: "9091",
},
Log: LogConfig{
Level: "debug",
Format: "text",
},
Metrics: MetricsConfig{
Enabled: false,
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Clear all env vars first
os.Unsetenv("SERVER_GRPC_PORT")
os.Unsetenv("SERVER_HTTP_PORT")
os.Unsetenv("LOG_LEVEL")
os.Unsetenv("LOG_FORMAT")
os.Unsetenv("METRICS_ENABLED")
// Set test env vars
for key, value := range tt.envVars {
os.Setenv(key, value)
}
cfg := Load()
if cfg.Server.GRPCPort != tt.expected.Server.GRPCPort {
t.Errorf("expected GRPCPort %s, got %s", tt.expected.Server.GRPCPort, cfg.Server.GRPCPort)
}
if cfg.Server.HTTPPort != tt.expected.Server.HTTPPort {
t.Errorf("expected HTTPPort %s, got %s", tt.expected.Server.HTTPPort, cfg.Server.HTTPPort)
}
if cfg.Log.Level != tt.expected.Log.Level {
t.Errorf("expected LogLevel %s, got %s", tt.expected.Log.Level, cfg.Log.Level)
}
if cfg.Log.Format != tt.expected.Log.Format {
t.Errorf("expected LogFormat %s, got %s", tt.expected.Log.Format, cfg.Log.Format)
}
if cfg.Metrics.Enabled != tt.expected.Metrics.Enabled {
t.Errorf("expected MetricsEnabled %t, got %t", tt.expected.Metrics.Enabled, cfg.Metrics.Enabled)
}
})
}
}
func TestValidate(t *testing.T) {
tests := []struct {
name string
config Config
wantErr bool
}{
{
name: "valid config",
config: Config{
Server: ServerConfig{
GRPCPort: "8080",
HTTPPort: "8090",
},
Log: LogConfig{
Level: "info",
Format: "json",
},
},
wantErr: false,
},
{
name: "empty grpc port",
config: Config{
Server: ServerConfig{
GRPCPort: "",
HTTPPort: "8090",
},
Log: LogConfig{
Level: "info",
Format: "json",
},
},
wantErr: true,
},
{
name: "same ports",
config: Config{
Server: ServerConfig{
GRPCPort: "8080",
HTTPPort: "8080",
},
Log: LogConfig{
Level: "info",
Format: "json",
},
},
wantErr: true,
},
{
name: "invalid log level",
config: Config{
Server: ServerConfig{
GRPCPort: "8080",
HTTPPort: "8090",
},
Log: LogConfig{
Level: "invalid",
Format: "json",
},
},
wantErr: true,
},
{
name: "invalid log format",
config: Config{
Server: ServerConfig{
GRPCPort: "8080",
HTTPPort: "8090",
},
Log: LogConfig{
Level: "info",
Format: "invalid",
},
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.config.Validate()
if (err != nil) != tt.wantErr {
t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}

112
internal/health/health.go Normal file
View File

@@ -0,0 +1,112 @@
package health
import (
"encoding/json"
"net/http"
"time"
)
// Response represents a health check response
type Response struct {
Status string `json:"status"`
Timestamp time.Time `json:"timestamp"`
Version string `json:"version,omitempty"`
Checks []Check `json:"checks,omitempty"`
}
// Check represents an individual health check
type Check struct {
Name string `json:"name"`
Status string `json:"status"`
Error string `json:"error,omitempty"`
}
// Handler handles health check requests
type Handler struct {
version string
checks []HealthChecker
}
// HealthChecker interface for health checks
type HealthChecker interface {
Name() string
Check() error
}
// NewHandler creates a new health check handler
func NewHandler(version string) *Handler {
return &Handler{
version: version,
checks: make([]HealthChecker, 0),
}
}
// AddCheck adds a health checker
func (h *Handler) AddCheck(checker HealthChecker) {
h.checks = append(h.checks, checker)
}
// ServeHTTP implements http.Handler
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
response := Response{
Status: "ok",
Timestamp: time.Now().UTC(),
Version: h.version,
Checks: make([]Check, 0, len(h.checks)),
}
allHealthy := true
for _, checker := range h.checks {
check := Check{
Name: checker.Name(),
Status: "ok",
}
if err := checker.Check(); err != nil {
check.Status = "error"
check.Error = err.Error()
allHealthy = false
}
response.Checks = append(response.Checks, check)
}
if !allHealthy {
response.Status = "error"
w.WriteHeader(http.StatusServiceUnavailable)
} else {
w.WriteHeader(http.StatusOK)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// SimpleChecker is a basic health checker
type SimpleChecker struct {
name string
fn func() error
}
// NewSimpleChecker creates a new simple health checker
func NewSimpleChecker(name string, fn func() error) *SimpleChecker {
return &SimpleChecker{
name: name,
fn: fn,
}
}
// Name returns the checker name
func (c *SimpleChecker) Name() string {
return c.name
}
// Check performs the health check
func (c *SimpleChecker) Check() error {
return c.fn()
}

View File

@@ -0,0 +1,115 @@
package health
import (
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
)
func TestHealthHandler_ServeHTTP(t *testing.T) {
tests := []struct {
name string
method string
checkers []HealthChecker
expectedStatus int
expectedHealth string
}{
{
name: "GET request with no checkers",
method: "GET",
checkers: []HealthChecker{},
expectedStatus: http.StatusOK,
expectedHealth: "ok",
},
{
name: "GET request with healthy checker",
method: "GET",
checkers: []HealthChecker{
NewSimpleChecker("test", func() error { return nil }),
},
expectedStatus: http.StatusOK,
expectedHealth: "ok",
},
{
name: "GET request with unhealthy checker",
method: "GET",
checkers: []HealthChecker{
NewSimpleChecker("test", func() error { return errors.New("test error") }),
},
expectedStatus: http.StatusServiceUnavailable,
expectedHealth: "error",
},
{
name: "POST request should return 405",
method: "POST",
checkers: []HealthChecker{},
expectedStatus: http.StatusMethodNotAllowed,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
handler := NewHandler("1.0.0")
for _, checker := range tt.checkers {
handler.AddCheck(checker)
}
req := httptest.NewRequest(tt.method, "/health", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != tt.expectedStatus {
t.Errorf("expected status %d, got %d", tt.expectedStatus, w.Code)
}
if tt.method == "GET" && tt.expectedStatus != http.StatusMethodNotAllowed {
var response Response
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if response.Status != tt.expectedHealth {
t.Errorf("expected status %s, got %s", tt.expectedHealth, response.Status)
}
if response.Version != "1.0.0" {
t.Errorf("expected version 1.0.0, got %s", response.Version)
}
if len(response.Checks) != len(tt.checkers) {
t.Errorf("expected %d checks, got %d", len(tt.checkers), len(response.Checks))
}
}
})
}
}
func TestSimpleChecker(t *testing.T) {
t.Run("healthy checker", func(t *testing.T) {
checker := NewSimpleChecker("test", func() error { return nil })
if checker.Name() != "test" {
t.Errorf("expected name 'test', got %s", checker.Name())
}
if err := checker.Check(); err != nil {
t.Errorf("expected no error, got %v", err)
}
})
t.Run("unhealthy checker", func(t *testing.T) {
testErr := errors.New("test error")
checker := NewSimpleChecker("test", func() error { return testErr })
if checker.Name() != "test" {
t.Errorf("expected name 'test', got %s", checker.Name())
}
if err := checker.Check(); err != testErr {
t.Errorf("expected error %v, got %v", testErr, err)
}
})
}

205
internal/server/server.go Normal file
View File

@@ -0,0 +1,205 @@
package server
import (
"context"
"fmt"
"log/slog"
"net"
"net/http"
"os"
"sync"
"time"
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/reflection"
"go-grpc-gateway-template/internal/config"
"go-grpc-gateway-template/internal/health"
helloworldpb "go-grpc-gateway-template/proto/helloworld"
)
// Server wraps gRPC and HTTP servers with configuration
type Server struct {
config *config.Config
logger *slog.Logger
grpcServer *grpc.Server
httpServer *http.Server
healthHandler *health.Handler
}
// New creates a new server instance
func New(cfg *config.Config) *Server {
// Setup logger
var handler slog.Handler
if cfg.Log.Format == "json" {
handler = slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: parseLogLevel(cfg.Log.Level),
})
} else {
handler = slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
Level: parseLogLevel(cfg.Log.Level),
})
}
logger := slog.New(handler)
// Create health handler
healthHandler := health.NewHandler("1.0.0")
return &Server{
config: cfg,
logger: logger,
healthHandler: healthHandler,
}
}
// Start starts both gRPC and HTTP servers
func (s *Server) Start(ctx context.Context) error {
var wg sync.WaitGroup
errChan := make(chan error, 2)
// Start gRPC server
wg.Add(1)
go func() {
defer wg.Done()
if err := s.startGRPCServer(ctx); err != nil {
errChan <- fmt.Errorf("gRPC server error: %w", err)
}
}()
// Start HTTP server
wg.Add(1)
go func() {
defer wg.Done()
if err := s.startHTTPServer(ctx); err != nil && err != http.ErrServerClosed {
errChan <- fmt.Errorf("HTTP server error: %w", err)
}
}()
// Wait for servers to start
go func() {
wg.Wait()
close(errChan)
}()
// Return first error if any
return <-errChan
}
func (s *Server) startGRPCServer(ctx context.Context) error {
lis, err := net.Listen("tcp", ":"+s.config.Server.GRPCPort)
if err != nil {
return fmt.Errorf("failed to listen on port %s: %w", s.config.Server.GRPCPort, err)
}
s.grpcServer = grpc.NewServer()
// Register services
greeter := &GreeterService{}
helloworldpb.RegisterGreeterServer(s.grpcServer, greeter)
// Enable reflection for development
reflection.Register(s.grpcServer)
s.logger.Info("Starting gRPC server", "port", s.config.Server.GRPCPort)
return s.grpcServer.Serve(lis)
}
func (s *Server) startHTTPServer(ctx context.Context) error {
// Create gRPC client connection
conn, err := grpc.DialContext(
ctx,
"localhost:"+s.config.Server.GRPCPort,
grpc.WithBlock(),
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
if err != nil {
return fmt.Errorf("failed to dial gRPC server: %w", err)
}
// Create gateway mux
gwMux := runtime.NewServeMux()
// Register gRPC gateway handlers
err = helloworldpb.RegisterGreeterHandler(ctx, gwMux, conn)
if err != nil {
return fmt.Errorf("failed to register gateway: %w", err)
}
// Create HTTP mux with health endpoint
mux := http.NewServeMux()
mux.Handle("/", gwMux)
mux.Handle("/health", s.healthHandler)
s.httpServer = &http.Server{
Addr: ":" + s.config.Server.HTTPPort,
Handler: mux,
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 120 * time.Second,
}
s.logger.Info("Starting HTTP server", "port", s.config.Server.HTTPPort)
return s.httpServer.ListenAndServe()
}
// Shutdown gracefully shuts down the servers
func (s *Server) Shutdown(ctx context.Context) error {
s.logger.Info("Shutting down servers...")
var wg sync.WaitGroup
// Shutdown HTTP server
if s.httpServer != nil {
wg.Add(1)
go func() {
defer wg.Done()
if err := s.httpServer.Shutdown(ctx); err != nil {
s.logger.Error("HTTP server shutdown error", "error", err)
}
}()
}
// Shutdown gRPC server
if s.grpcServer != nil {
wg.Add(1)
go func() {
defer wg.Done()
s.grpcServer.GracefulStop()
}()
}
wg.Wait()
s.logger.Info("Servers stopped")
return nil
}
// GreeterService implements the helloworld.Greeter service
type GreeterService struct {
helloworldpb.UnimplementedGreeterServer
}
// SayHello implements the SayHello RPC method
func (g *GreeterService) SayHello(ctx context.Context, req *helloworldpb.HelloRequest) (*helloworldpb.HelloReply, error) {
return &helloworldpb.HelloReply{
Message: "Hello " + req.GetName(),
}, nil
}
func parseLogLevel(level string) slog.Level {
switch level {
case "debug":
return slog.LevelDebug
case "info":
return slog.LevelInfo
case "warn":
return slog.LevelWarn
case "error":
return slog.LevelError
default:
return slog.LevelInfo
}
}