Compare commits
7 Commits
1.0.0
...
feat/yaml-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e187140c4 | ||
|
|
f3dedf47f3 | ||
|
|
1dde741885 | ||
|
|
4807edd23a | ||
|
|
ec689c6152 | ||
|
|
6623ced227 | ||
|
|
f932c0ff65 |
26
.github/workflows/ci.yml
vendored
26
.github/workflows/ci.yml
vendored
@@ -7,19 +7,19 @@ on:
|
|||||||
branches: [ "main" ]
|
branches: [ "main" ]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# lint:
|
lint:
|
||||||
# name: Lint
|
name: Lint
|
||||||
# runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
# steps:
|
steps:
|
||||||
# - uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
# - uses: actions/setup-go@v5
|
- uses: actions/setup-go@v5
|
||||||
# with:
|
with:
|
||||||
# go-version: '1.23'
|
go-version: '1.23'
|
||||||
# cache: false
|
cache: false
|
||||||
# - name: golangci-lint
|
- name: golangci-lint
|
||||||
# uses: golangci/golangci-lint-action@v6
|
uses: golangci/golangci-lint-action@v6
|
||||||
# with:
|
with:
|
||||||
# version: v2.8.0
|
version: latest
|
||||||
|
|
||||||
test:
|
test:
|
||||||
name: Test
|
name: Test
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
# Options for analysis running.
|
# Options for analysis running.
|
||||||
version: 2
|
|
||||||
run:
|
run:
|
||||||
timeout: 5m
|
timeout: 5m
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,11 @@ make test
|
|||||||
make lint
|
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
|
## Docker
|
||||||
|
|
||||||
Build the Docker image:
|
Build the Docker image:
|
||||||
|
|||||||
@@ -2,16 +2,33 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/placeholder/golang-template/internal/config"
|
||||||
|
"github.com/placeholder/golang-template/internal/logging"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
// Parse CLI flags
|
||||||
slog.SetDefault(logger)
|
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())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|||||||
12
config/config.yaml
Normal file
12
config/config.yaml
Normal 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
45
docs/configuration.md
Normal 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
24
docs/logging.md
Normal 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
2
go.mod
@@ -1,3 +1,5 @@
|
|||||||
module github.com/placeholder/golang-template
|
module github.com/placeholder/golang-template
|
||||||
|
|
||||||
go 1.23
|
go 1.23
|
||||||
|
|
||||||
|
require gopkg.in/yaml.v3 v3.0.1
|
||||||
|
|||||||
4
go.sum
Normal file
4
go.sum
Normal 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
144
internal/config/config.go
Normal 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
|
||||||
|
}
|
||||||
65
internal/config/config_test.go
Normal file
65
internal/config/config_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
37
internal/logging/logging.go
Normal file
37
internal/logging/logging.go
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
37
internal/logging/logging_test.go
Normal file
37
internal/logging/logging_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user