This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
package configuration
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v2"
|
||||
)
|
||||
|
||||
const defaultTimeoutSeconds = 10
|
||||
|
||||
// Configuration contains the HTTP server and Shelly device configuration.
|
||||
type Configuration struct {
|
||||
Host string `yaml:"host"`
|
||||
Port uint16 `yaml:"port"`
|
||||
LogLevel int `yaml:"logLevel"`
|
||||
Shelly ShellyConfiguration `yaml:"shelly"`
|
||||
}
|
||||
|
||||
// ShellyConfiguration controls communication with all configured devices.
|
||||
type ShellyConfiguration struct {
|
||||
TimeoutSeconds int `yaml:"timeoutSeconds"`
|
||||
Devices []DeviceConfiguration `yaml:"devices"`
|
||||
}
|
||||
|
||||
// DeviceConfiguration identifies a single Shelly device and its product implementation.
|
||||
type DeviceConfiguration struct {
|
||||
Name string `yaml:"name"`
|
||||
Product string `yaml:"product"`
|
||||
URL string `yaml:"url"`
|
||||
Username string `yaml:"username"`
|
||||
Password string `yaml:"password"`
|
||||
}
|
||||
|
||||
// Load reads and strictly validates a YAML configuration file.
|
||||
func Load(path string) (*Configuration, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read configuration %q: %w", path, err)
|
||||
}
|
||||
|
||||
var config Configuration
|
||||
if err := yaml.UnmarshalStrict(data, &config); err != nil {
|
||||
return nil, fmt.Errorf("parse configuration %q: %w", path, err)
|
||||
}
|
||||
config.applyDefaults()
|
||||
if err := config.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
func (c *Configuration) applyDefaults() {
|
||||
if c.Shelly.TimeoutSeconds == 0 {
|
||||
c.Shelly.TimeoutSeconds = defaultTimeoutSeconds
|
||||
}
|
||||
}
|
||||
|
||||
// Validate checks fields that are independent of a concrete product implementation.
|
||||
func (c *Configuration) Validate() error {
|
||||
if net.ParseIP(c.Host) == nil {
|
||||
return fmt.Errorf("host is not a valid IP address: %q", c.Host)
|
||||
}
|
||||
if c.Port == 0 {
|
||||
return errors.New("port must be greater than zero")
|
||||
}
|
||||
if c.LogLevel < 0 || c.LogLevel > 6 {
|
||||
return errors.New("logLevel must be between 0 and 6")
|
||||
}
|
||||
if c.Shelly.TimeoutSeconds <= 0 {
|
||||
return errors.New("shelly.timeoutSeconds must be greater than zero")
|
||||
}
|
||||
if len(c.Shelly.Devices) == 0 {
|
||||
return errors.New("shelly.devices cannot be empty")
|
||||
}
|
||||
|
||||
names := make(map[string]struct{}, len(c.Shelly.Devices))
|
||||
for index, device := range c.Shelly.Devices {
|
||||
prefix := fmt.Sprintf("shelly.devices[%d]", index)
|
||||
if strings.TrimSpace(device.Name) == "" {
|
||||
return fmt.Errorf("%s.name cannot be empty", prefix)
|
||||
}
|
||||
if _, exists := names[device.Name]; exists {
|
||||
return fmt.Errorf("device name %q is configured more than once", device.Name)
|
||||
}
|
||||
names[device.Name] = struct{}{}
|
||||
|
||||
if strings.TrimSpace(device.Product) == "" {
|
||||
return fmt.Errorf("%s.product cannot be empty", prefix)
|
||||
}
|
||||
parsedURL, err := url.ParseRequestURI(device.URL)
|
||||
if err != nil || parsedURL.Host == "" || (parsedURL.Scheme != "http" && parsedURL.Scheme != "https") {
|
||||
return fmt.Errorf("%s.url must be an absolute HTTP or HTTPS URL", prefix)
|
||||
}
|
||||
if parsedURL.RawQuery != "" || parsedURL.Fragment != "" {
|
||||
return fmt.Errorf("%s.url cannot contain a query or fragment", prefix)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package configuration
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func validConfiguration() Configuration {
|
||||
return Configuration{
|
||||
Host: "0.0.0.0",
|
||||
Port: 9090,
|
||||
LogLevel: 4,
|
||||
Shelly: ShellyConfiguration{
|
||||
TimeoutSeconds: 10,
|
||||
Devices: []DeviceConfiguration{
|
||||
{Name: "office", Product: "plug_s", URL: "http://192.168.0.30", Username: "admin", Password: "secret"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigurationValidate(t *testing.T) {
|
||||
config := validConfiguration()
|
||||
if err := config.Validate(); err != nil {
|
||||
t.Fatalf("Validate() returned an unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigurationRejectsDuplicateDeviceNames(t *testing.T) {
|
||||
config := validConfiguration()
|
||||
config.Shelly.Devices = append(config.Shelly.Devices, config.Shelly.Devices[0])
|
||||
|
||||
err := config.Validate()
|
||||
if err == nil || !strings.Contains(err.Error(), "configured more than once") {
|
||||
t.Fatalf("Validate() error = %v, want duplicate name error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadAppliesTimeoutDefaultAndRejectsUnknownFields(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
validPath := filepath.Join(directory, "valid.yaml")
|
||||
validYAML := []byte(`host: 0.0.0.0
|
||||
port: 9090
|
||||
logLevel: 4
|
||||
shelly:
|
||||
devices:
|
||||
- name: office
|
||||
product: plug_s
|
||||
url: http://192.168.0.30
|
||||
`)
|
||||
if err := os.WriteFile(validPath, validYAML, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
config, err := Load(validPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Load() returned an unexpected error: %v", err)
|
||||
}
|
||||
if config.Shelly.TimeoutSeconds != defaultTimeoutSeconds {
|
||||
t.Fatalf("TimeoutSeconds = %d, want %d", config.Shelly.TimeoutSeconds, defaultTimeoutSeconds)
|
||||
}
|
||||
|
||||
invalidPath := filepath.Join(directory, "invalid.yaml")
|
||||
if err := os.WriteFile(invalidPath, append(validYAML, []byte("unknown: true\n")...), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := Load(invalidPath); err == nil {
|
||||
t.Fatal("Load() accepted an unknown field")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user