feat: config and config loader implemented
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/knadh/koanf/parsers/toml/v2"
|
||||
"github.com/knadh/koanf/providers/confmap"
|
||||
"github.com/knadh/koanf/providers/file"
|
||||
"github.com/knadh/koanf/v2"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
MQTT struct {
|
||||
URL string `koanf:"url"`
|
||||
ClientID string `koanf:"client_id"`
|
||||
Username string `koanf:"username"`
|
||||
Password string `koanf:"password"`
|
||||
DeviceID string `koanf:"device_id"`
|
||||
} `koanf:"mqtt"`
|
||||
App struct {
|
||||
ConnectTimeout string `koanf:"connect_timeout"`
|
||||
} `koanf:"app"`
|
||||
}
|
||||
|
||||
// Keep duration input as text; validate/parse it explicitly after merging.
|
||||
func (c Config) ConnectTimeout() time.Duration {
|
||||
d, _ := time.ParseDuration(c.App.ConnectTimeout) // Load already validated it.
|
||||
return d
|
||||
}
|
||||
|
||||
func Load(args []string) (Config, error) {
|
||||
var result Config
|
||||
flags := flag.NewFlagSet("mqtt5-learning", flag.ContinueOnError)
|
||||
filename := flags.String("config", "configs/lab.toml", "TOML file")
|
||||
broker := flags.String("broker", "", "broker URL override")
|
||||
clientID := flags.String("client-id", "", "client ID override")
|
||||
username := flags.String("username", "", "lab username override")
|
||||
deviceID := flags.String("device-id", "", "device identity override")
|
||||
timeout := flags.String("connect-timeout", "", "startup deadline override")
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return result, err
|
||||
}
|
||||
if flags.NArg() != 0 {
|
||||
return result, fmt.Errorf("unexpected positional arguments")
|
||||
}
|
||||
|
||||
k := koanf.New(".")
|
||||
defaults := map[string]any{
|
||||
"mqtt.url": "mqtt://127.0.0.1:18883",
|
||||
"mqtt.client_id": "lab-backend",
|
||||
"mqtt.username": "", "mqtt.password": "",
|
||||
"mqtt.device_id": "device-001",
|
||||
"app.connect_timeout": "10s",
|
||||
}
|
||||
if err := k.Load(confmap.Provider(defaults, "."), nil); err != nil {
|
||||
return result, fmt.Errorf("defaults: %w", err)
|
||||
}
|
||||
// A requested/missing config file is an error, not a silent fallback.
|
||||
if err := k.Load(file.Provider(*filename), toml.Parser()); err != nil {
|
||||
return result, fmt.Errorf("TOML: %w", err)
|
||||
}
|
||||
|
||||
// Explicit mapping preserves underscores inside names such as client_id.
|
||||
mapping := map[string]string{
|
||||
"TEST_MQTT5_MQTT_URL": "mqtt.url",
|
||||
"TEST_MQTT5_MQTT_CLIENT_ID": "mqtt.client_id",
|
||||
"TEST_MQTT5_MQTT_USERNAME": "mqtt.username",
|
||||
"TEST_MQTT5_MQTT_PASSWORD": "mqtt.password",
|
||||
"TEST_MQTT5_MQTT_DEVICE_ID": "mqtt.device_id",
|
||||
"TEST_MQTT5_APP_CONNECT_TIMEOUT": "app.connect_timeout",
|
||||
}
|
||||
for name, key := range mapping {
|
||||
if value, exists := os.LookupEnv(name); exists {
|
||||
if err := k.Set(key, value); err != nil {
|
||||
return result, err
|
||||
}
|
||||
}
|
||||
}
|
||||
overrides := map[string]string{}
|
||||
flags.Visit(func(f *flag.Flag) {
|
||||
switch f.Name {
|
||||
case "broker":
|
||||
overrides["mqtt.url"] = *broker
|
||||
case "client-id":
|
||||
overrides["mqtt.client_id"] = *clientID
|
||||
case "username":
|
||||
overrides["mqtt.username"] = *username
|
||||
case "device-id":
|
||||
overrides["mqtt.device_id"] = *deviceID
|
||||
case "connect-timeout":
|
||||
overrides["app.connect_timeout"] = *timeout
|
||||
}
|
||||
})
|
||||
for key, value := range overrides {
|
||||
if err := k.Set(key, value); err != nil {
|
||||
return result, err
|
||||
}
|
||||
}
|
||||
if err := k.Unmarshal("", &result); err != nil {
|
||||
return result, fmt.Errorf("decode configuration: %w", err)
|
||||
}
|
||||
if err := result.Validate(); err != nil {
|
||||
return result, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (c Config) Validate() error {
|
||||
u, err := url.Parse(c.MQTT.URL)
|
||||
if err != nil || u == nil || u.Hostname() == "" || u.Port() == "" {
|
||||
return fmt.Errorf("mqtt.url must contain a valid host and explicit port")
|
||||
}
|
||||
// Early lab only. TLS configuration is deliberately introduced later.
|
||||
if u.Scheme != "mqtt" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || u.Path != "" {
|
||||
return fmt.Errorf("early lab requires mqtt://host:port without credentials or extra URL components")
|
||||
}
|
||||
if u.Hostname() != "127.0.0.1" && u.Hostname() != "localhost" {
|
||||
return fmt.Errorf("plaintext learning broker must be on loopback")
|
||||
}
|
||||
for _, value := range []string{c.MQTT.ClientID, c.MQTT.Username, c.MQTT.Password} {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return fmt.Errorf("client ID and credentials are required")
|
||||
}
|
||||
}
|
||||
if c.MQTT.DeviceID == "" || strings.ContainsAny(c.MQTT.DeviceID, "/+# \t\r\n") {
|
||||
return fmt.Errorf("device ID must be a nonempty single topic segment")
|
||||
}
|
||||
d, err := time.ParseDuration(c.App.ConnectTimeout)
|
||||
if err != nil || d <= 0 || d > time.Minute {
|
||||
return fmt.Errorf("connect timeout must be a duration greater than zero and at most 1m")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user