diff --git a/README.md b/README.md index 646dff0..274c252 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,11 @@ make test make lint ``` +## Documentation + +- [Configuration Guide](docs/configuration.md): Details on YAML config, environment variables, and CLI flags. +- [Logging Guide](docs/logging.md): Information about log levels and structured logging format. + ## Docker Build the Docker image: diff --git a/cmd/app/main.go b/cmd/app/main.go index c87ad03..08f2fce 100644 --- a/cmd/app/main.go +++ b/cmd/app/main.go @@ -2,16 +2,33 @@ package main import ( "context" + "flag" + "fmt" "log/slog" "os" "os/signal" "syscall" "time" + + "github.com/placeholder/golang-template/internal/config" + "github.com/placeholder/golang-template/internal/logging" ) func main() { - logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) - slog.SetDefault(logger) + // Parse CLI flags + var configPath string + flag.StringVar(&configPath, "config", "config/config.yaml", "path to config file") + flag.Parse() + + // Load Configuration + cfg, err := config.Load(configPath, "APP") + if err != nil { + fmt.Fprintf(os.Stderr, "failed to load config: %v\n", err) + os.Exit(1) + } + + // Setup Logger + logging.Configure(cfg.Logging) ctx, cancel := context.WithCancel(context.Background()) defer cancel() diff --git a/config/config.yaml b/config/config.yaml new file mode 100644 index 0000000..327f910 --- /dev/null +++ b/config/config.yaml @@ -0,0 +1,12 @@ +# Application Configuration +# This file contains the default configuration for the application. +# Values can be overridden by environment variables with the prefix "APP_". +# Example: APP_LOGGING_LEVEL=debug + +logging: + # Log Level: debug, info, warn, error + level: "info" + +server: + host: "localhost" + port: 8080 \ No newline at end of file diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..d699f86 --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,45 @@ +# Configuration + +This application uses a flexible configuration system that supports YAML files and environment variable overrides. + +## Configuration File + +The configuration is loaded from a YAML file. By default, the application looks for `config/config.yaml`. You can specify a different configuration file using the `-config` CLI flag. + +```bash +./app -config /path/to/my-config.yaml +``` + +### Structure + +The configuration file supports the following structure: + +```yaml +# Logging configuration +logging: + # Log Level: debug, info, warn, error + level: "info" + +# Server configuration +server: + host: "localhost" + port: 8080 +``` + +## Environment Variables + +All configuration values can be overridden using environment variables. The application uses the `APP_` prefix for all environment variables. The variable name is constructed by capitalizing the path to the configuration value and replacing dots/nesting with underscores. + +| YAML Path | Environment Variable | Description | +| :--- | :--- | :--- | +| `logging.level` | `APP_LOGGING_LEVEL` | Sets the logging level (debug, info, warn, error) | +| `server.host` | `APP_SERVER_HOST` | Sets the server host address | +| `server.port` | `APP_SERVER_PORT` | Sets the server listening port | + +**Example:** + +To start the application with `debug` logging and listening on port `9090`: + +```bash +APP_LOGGING_LEVEL=debug APP_SERVER_PORT=9090 ./app +``` \ No newline at end of file diff --git a/docs/logging.md b/docs/logging.md new file mode 100644 index 0000000..bd0c0ef --- /dev/null +++ b/docs/logging.md @@ -0,0 +1,24 @@ +# Logging + +The application uses structured logging (JSON format) via the standard library's `log/slog` package. + +## Log Levels + +The supported log levels are: + +* `debug`: Detailed information for debugging purposes. +* `info`: General operational events (startup, shutdown, etc.). +* `warn`: Non-critical errors or potential issues. +* `error`: Critical errors that require attention. + +The log level is configured via the `logging.level` setting in the configuration file or the `APP_LOGGING_LEVEL` environment variable. + +## Log Output + +Logs are output to `stdout` in JSON format to facilitate parsing by log management systems. + +**Example Log Entry:** + +```json +{"time":"2023-10-27T10:00:00.123Z","level":"INFO","msg":"Starting application..."} +``` diff --git a/go.mod b/go.mod index 3c90b52..c492fcb 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,5 @@ module github.com/placeholder/golang-template go 1.23 + +require gopkg.in/yaml.v3 v3.0.1 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a62c313 --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..fdc8119 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,144 @@ +package config + +import ( + "fmt" + "os" + "reflect" + "strconv" + "strings" + "time" + + "github.com/placeholder/golang-template/internal/logging" + "gopkg.in/yaml.v3" +) + +// Config holds the application configuration. +type Config struct { + Logging logging.Config `yaml:"logging"` + Server ServerConfig `yaml:"server"` +} + +// ServerConfig holds server-specific configuration. +type ServerConfig struct { + Host string `yaml:"host"` + Port int `yaml:"port"` +} + +// Load reads configuration from a YAML file and overrides it with environment variables. +// The envPrefix is used to namespace environment variables (e.g. "APP"). +// Env vars should be in the format: PREFIX_FIELD_NAME (uppercase). +func Load(configPath string, envPrefix string) (*Config, error) { + cfg := &Config{} + + // 1. Read YAML file + file, err := os.Open(configPath) + if err != nil { + return nil, fmt.Errorf("failed to open config file: %w", err) + } + defer file.Close() + + decoder := yaml.NewDecoder(file) + if err := decoder.Decode(cfg); err != nil { + return nil, fmt.Errorf("failed to decode config file: %w", err) + } + + // 2. Override from Environment Variables + if err := overrideFromEnv(cfg, envPrefix); err != nil { + return nil, fmt.Errorf("failed to process environment variables: %w", err) + } + + return cfg, nil +} + +// overrideFromEnv uses reflection to traverse the struct and update fields from env vars. +func overrideFromEnv(cfg interface{}, prefix string) error { + v := reflect.ValueOf(cfg) + if v.Kind() == reflect.Ptr { + v = v.Elem() + } + + return setFromEnv(v, prefix) +} + +func setFromEnv(v reflect.Value, prefix string) error { + t := v.Type() + + for i := 0; i < v.NumField(); i++ { + field := t.Field(i) + fieldVal := v.Field(i) + + // Determine the key for this field + // We use the yaml tag if available to guess the name, or just the field name + keyName := field.Name + if tag := field.Tag.Get("yaml"); tag != "" { + parts := strings.Split(tag, ",") + if parts[0] != "" { + keyName = parts[0] + } + } + + // Sanitize key name for Env (uppercase, replace non-alphanum with _) + envKey := strings.ToUpper(fmt.Sprintf("%s_%s", prefix, keyName)) + // Clean up common separators like hyphens in yaml keys + envKey = strings.ReplaceAll(envKey, "-", "_") + + if fieldVal.Kind() == reflect.Struct { + // Recursive call for nested structs + if err := setFromEnv(fieldVal, envKey); err != nil { + return err + } + continue + } + + if !fieldVal.CanSet() { + continue + } + + val, present := os.LookupEnv(envKey) + if present { + if err := setField(fieldVal, val); err != nil { + return fmt.Errorf("failed to set field %s from env %s: %w", field.Name, envKey, err) + } + } + } + return nil +} + +func setField(field reflect.Value, value string) error { + switch field.Kind() { + case reflect.String: + field.SetString(value) + case reflect.Bool: + b, err := strconv.ParseBool(value) + if err != nil { + return err + } + field.SetBool(b) + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + // Handle Duration specially if it's an int64 underlying type? + // For now, let's assume standard ints. + // Real generic implementations often handle time.Duration + if field.Type() == reflect.TypeOf(time.Duration(0)) { + d, err := time.ParseDuration(value) + if err != nil { + return err + } + field.SetInt(int64(d)) + } else { + i, err := strconv.ParseInt(value, 10, 64) + if err != nil { + return err + } + field.SetInt(i) + } + case reflect.Float32, reflect.Float64: + f, err := strconv.ParseFloat(value, 64) + if err != nil { + return err + } + field.SetFloat(f) + default: + return fmt.Errorf("unsupported type: %s", field.Kind()) + } + return nil +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..6623ae8 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,65 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoad(t *testing.T) { + // Create a temporary config file + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "config.yaml") + + yamlContent := []byte(` +logging: + level: "info" +server: + host: "localhost" + port: 8080 +`) + if err := os.WriteFile(configFile, yamlContent, 0644); err != nil { + t.Fatalf("failed to write temp config file: %v", err) + } + + t.Run("Load from YAML", func(t *testing.T) { + cfg, err := Load(configFile, "TEST") + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if cfg.Logging.Level != "info" { + t.Errorf("expected Logging.Level 'info', got '%s'", cfg.Logging.Level) + } + if cfg.Server.Host != "localhost" { + t.Errorf("expected Server.Host 'localhost', got '%s'", cfg.Server.Host) + } + if cfg.Server.Port != 8080 { + t.Errorf("expected Server.Port 8080, got %d", cfg.Server.Port) + } + }) + + t.Run("Override from Env", func(t *testing.T) { + os.Setenv("TEST_LOGGING_LEVEL", "debug") + os.Setenv("TEST_SERVER_PORT", "9090") + os.Setenv("TEST_SERVER_HOST", "0.0.0.0") + defer func() { + os.Unsetenv("TEST_LOGGING_LEVEL") + os.Unsetenv("TEST_SERVER_PORT") + os.Unsetenv("TEST_SERVER_HOST") + }() + + cfg, err := Load(configFile, "TEST") + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if cfg.Logging.Level != "debug" { + t.Errorf("expected Logging.Level 'debug', got '%s'", cfg.Logging.Level) + } + if cfg.Server.Port != 9090 { + t.Errorf("expected Server.Port 9090, got %d", cfg.Server.Port) + } + if cfg.Server.Host != "0.0.0.0" { + t.Errorf("expected Server.Host '0.0.0.0', got '%s'", cfg.Server.Host) + } + }) +} diff --git a/internal/logging/logging.go b/internal/logging/logging.go new file mode 100644 index 0000000..bf8dba0 --- /dev/null +++ b/internal/logging/logging.go @@ -0,0 +1,37 @@ +package logging + +import ( + "log/slog" + "os" + "strings" +) + +// Config holds the logging configuration. +type Config struct { + Level string `yaml:"level"` +} + +// Configure sets up the global logger based on the provided configuration. +func Configure(cfg Config) *slog.Logger { + opts := &slog.HandlerOptions{ + Level: parseLogLevel(cfg.Level), + } + logger := slog.New(slog.NewJSONHandler(os.Stdout, opts)) + slog.SetDefault(logger) + return logger +} + +func parseLogLevel(level string) slog.Level { + switch strings.ToLower(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 + } +} diff --git a/internal/logging/logging_test.go b/internal/logging/logging_test.go new file mode 100644 index 0000000..eb30f0a --- /dev/null +++ b/internal/logging/logging_test.go @@ -0,0 +1,37 @@ +package logging + +import ( + "log/slog" + "testing" +) + +func TestParseLogLevel(t *testing.T) { + tests := []struct { + input string + expected slog.Level + }{ + {"debug", slog.LevelDebug}, + {"DEBUG", slog.LevelDebug}, + {"info", slog.LevelInfo}, + {"warn", slog.LevelWarn}, + {"error", slog.LevelError}, + {"unknown", slog.LevelInfo}, + {"", slog.LevelInfo}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + if got := parseLogLevel(tt.input); got != tt.expected { + t.Errorf("parseLogLevel(%q) = %v, want %v", tt.input, got, tt.expected) + } + }) + } +} + +func TestConfigure(t *testing.T) { + cfg := Config{Level: "debug"} + logger := Configure(cfg) + if logger == nil { + t.Error("Configure() returned nil") + } +}