6 Commits

Author SHA1 Message Date
Gemini CLI
1e187140c4 docs: separate configuration and logging documentation
All checks were successful
CI / Lint (pull_request) Successful in 5s
CI / Test (pull_request) Successful in 7s
CI / Build (pull_request) Successful in 6s
CI / Docker Build (pull_request) Successful in 20s
Extracts logging documentation into its own file (docs/logging.md) and updates README.md to link to both configuration and logging guides.
2026-01-15 19:41:07 +00:00
Gemini CLI
f3dedf47f3 refactor(config): decouple logging config and update readme
All checks were successful
CI / Lint (pull_request) Successful in 5s
CI / Test (pull_request) Successful in 8s
CI / Build (pull_request) Successful in 7s
CI / Docker Build (pull_request) Successful in 25s
Refactors internal/config to use logging.Config directly, removing duplication. Updates README.md to reference configuration documentation.
2026-01-15 19:37:13 +00:00
Gemini CLI
1dde741885 docs: add configuration and logging documentation
All checks were successful
CI / Lint (pull_request) Successful in 5s
CI / Test (pull_request) Successful in 8s
CI / Build (pull_request) Successful in 8s
CI / Docker Build (pull_request) Successful in 20s
Adds docs/configuration.md detailing the YAML config structure, environment variable overrides, and logging features.
2026-01-15 18:57:58 +00:00
Gemini CLI
4807edd23a refactor(logging): move logging setup to internal/logging
All checks were successful
CI / Lint (pull_request) Successful in 5s
CI / Test (pull_request) Successful in 7s
CI / Build (pull_request) Successful in 7s
CI / Docker Build (pull_request) Successful in 21s
Moves logging configuration and setup to a dedicated package. Updates Config struct to use nested LoggingConfig. Updates main.go to use the new logging package.
2026-01-15 18:57:01 +00:00
Gemini CLI
ec689c6152 feat(config): support cli config path and nested config
All checks were successful
CI / Lint (pull_request) Successful in 5s
CI / Test (pull_request) Successful in 8s
CI / Build (pull_request) Successful in 7s
CI / Docker Build (pull_request) Successful in 23s
Adds -config CLI flag to main.go. Moves ParseLogLevel to internal/config. Adds ServerConfig nested struct to Config and corresponding tests for yaml/env overrides.
2026-01-15 18:37:01 +00:00
Gemini CLI
6623ced227 feat(config): add yaml config with generic env override
All checks were successful
CI / Test (pull_request) Successful in 23s
CI / Build (pull_request) Successful in 7s
CI / Docker Build (pull_request) Successful in 21s
CI / Lint (pull_request) Successful in 7s
Adds internal/config package using gopkg.in/yaml.v3 and reflection for generic environment variable overrides. Updates main.go to load config and set log level. Adds tests and example config.
2026-01-15 18:28:17 +00:00
11 changed files with 394 additions and 2 deletions

View File

@@ -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:

View File

@@ -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()

12
config/config.yaml Normal file
View File

@@ -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

45
docs/configuration.md Normal file
View File

@@ -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
```

24
docs/logging.md Normal file
View File

@@ -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..."}
```

2
go.mod
View File

@@ -1,3 +1,5 @@
module github.com/placeholder/golang-template
go 1.23
require gopkg.in/yaml.v3 v3.0.1

4
go.sum Normal file
View File

@@ -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=

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

@@ -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
}

View File

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

View File

@@ -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
}
}

View File

@@ -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")
}
}