diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 8ac0a11..38114ee 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -3,13 +3,23 @@ FROM golang:1.25.4-trixie # Install basic dependencies RUN apt update && \ apt install -y \ - git \ curl \ + git \ + tar \ && \ rm -rf /var/lib/apt/lists/* # Install task -RUN sh -c "$(curl --location https://taskfile.dev/install.sh)" -- -d v3.36.0 +RUN sh -c "$(curl -L https://taskfile.dev/install.sh)" -- -d v3.36.0 # Install golangci-lint -RUN sh -c "$(curl --location https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh)" -- v2.6.2 +RUN sh -c "$(curl -L https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh)" -- v2.6.2 + +# Install gotestsum +RUN curl -L https://github.com/gotestyourself/gotestsum/releases/download/v1.13.0/gotestsum_1.13.0_linux_amd64.tar.gz -o gotestsum.tar.gz && \ + tar -xzf gotestsum.tar.gz -C /usr/local/bin gotestsum && \ + rm gotestsum.tar.gz + +# Install jd +RUN curl -L https://github.com/josephburnett/jd/releases/download/v2.3.0/jd-amd64-linux -o /usr/local/bin/jd && \ + chmod +x /usr/local/bin/jd diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index d77d15d..7d5b286 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,5 +1,5 @@ { - "image": "ghcr.io/codetent/confless/dev:sha-382640a", + "image": "ghcr.io/codetent/confless/dev:sha-6fd6dc8", "runArgs": [ "--platform=linux/amd64" ], diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 3ba528c..600ff42 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -13,7 +13,7 @@ jobs: check: runs-on: ubuntu-latest container: - image: ghcr.io/codetent/confless/dev:sha-382640a + image: ghcr.io/codetent/confless/dev:sha-6fd6dc8 env: GOFLAGS: -buildvcs=false steps: diff --git a/README.md b/README.md index 2fe16b8..de56691 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,7 @@ Sources are applied in the following order (later sources override earlier ones) 1. **Files** (in registration order) 2. **Command-line flags** 3. **Environment variables** (highest precedence) +4. **Dynamically registered files** ### Files @@ -112,7 +113,9 @@ database: You can mark a field in your configuration with the `confless:"file"` tag to automatically load it as a configuration file. This is useful for environment-specific configurations. -The format can be specified explicitly in the tag (`confless:"file,format=json"`) otherwise it defaults to JSON. +The format can be specified explicitly in the tag (`confless:"file,format=yaml"`) otherwise it defaults to JSON. + +Note that dynamically registered files are loaded at the end, while statically registered files are loaded first. ```go type Config struct { diff --git a/Taskfile.yml b/Taskfile.yml index 42b4d70..ce13f6e 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -1,9 +1,15 @@ version: '3' +includes: + examples: + dir: examples + taskfile: examples/Taskfile.yml + tasks: test: cmds: - - go test -v ./... + - gotestsum --format testdox + - task: examples:test lint: cmds: diff --git a/confless.go b/confless.go index 578aecf..433b82e 100644 --- a/confless.go +++ b/confless.go @@ -6,6 +6,12 @@ var ( defaultLoader = NewLoader() ) +// Configure the default loader with the given options. +// Note that reconfiguring the default loader will reset the registered sources. +func Configure(opts ...loaderOption) { + defaultLoader = NewLoader(opts...) +} + // Register an environment variable prefix to load. // Names are converted to dot-separated paths (e.g. "MY_FLAG" -> "my.flag"). func RegisterEnv(pre string) { diff --git a/example/config.json b/example/config.json deleted file mode 100644 index 02ec0f5..0000000 --- a/example/config.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "John", - "age": 42 -} diff --git a/example/main.go b/example/main.go deleted file mode 100644 index d91120e..0000000 --- a/example/main.go +++ /dev/null @@ -1,45 +0,0 @@ -package main - -import ( - "flag" - "fmt" - "time" - - "github.com/codetent/confless" -) - -type demo struct { - Name string - Config string `json:"config" confless:"file"` - Age int `json:"age"` - Objects struct { - Apple string - } - Items []int - CreatedAt time.Time -} - -func init() { - flag.String("name", "", "the name of the object") -} - -func main() { - obj := &demo{ - Name: "Alice", - Items: []int{0, 0}, - Config: "other.json", - } - - confless.RegisterEnv("example") - confless.RegisterFile("config.json", confless.FileFormatJSON) - confless.RegisterFlags(flag.CommandLine) - - flag.Parse() - - err := confless.Load(obj) - if err != nil { - fmt.Println("failed to load:", err) - } - - fmt.Println(obj) -} diff --git a/example/other.json b/example/other.json deleted file mode 100644 index 0ee19ab..0000000 --- a/example/other.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "objects": { - "apple": "foo" - } -} \ No newline at end of file diff --git a/examples/Taskfile.yml b/examples/Taskfile.yml new file mode 100644 index 0000000..497b25c --- /dev/null +++ b/examples/Taskfile.yml @@ -0,0 +1,17 @@ +version: '3' + +tasks: + test: + cmds: + - cmd: | + cd {{ .ITEM.dir }} && + {{ .ITEM.cmd }} | jd {{ .ITEM.expected }} + for: + - dir: simple + cmd: >- + APP_DATABASE_USERNAME=user + APP_DATABASE_PASSWORD=pass + go run main.go + --debug + --config=files/override.yaml + expected: expected.json diff --git a/examples/simple/expected.json b/examples/simple/expected.json new file mode 100644 index 0000000..3ad4dd4 --- /dev/null +++ b/examples/simple/expected.json @@ -0,0 +1,13 @@ +{ + "name": "app", + "host": "0.0.0.0", + "port": 8080, + "debug": true, + "database": { + "host": "db.example.com", + "port": 5432, + "username": "user", + "password": "pass" + }, + "Config": "files/override.yaml" +} diff --git a/examples/simple/files/config.json b/examples/simple/files/config.json new file mode 100644 index 0000000..5f5c897 --- /dev/null +++ b/examples/simple/files/config.json @@ -0,0 +1,5 @@ +{ + "name": "app", + "host": "0.0.0.0", + "port": 8080 +} diff --git a/examples/simple/files/override.yaml b/examples/simple/files/override.yaml new file mode 100644 index 0000000..98c310e --- /dev/null +++ b/examples/simple/files/override.yaml @@ -0,0 +1,4 @@ +database: + host: db.example.com + port: 5432 + ssl: true diff --git a/examples/simple/main.go b/examples/simple/main.go new file mode 100644 index 0000000..e8efb58 --- /dev/null +++ b/examples/simple/main.go @@ -0,0 +1,67 @@ +package main + +import ( + "encoding/json" + "flag" + "log" + "os" + + "github.com/codetent/confless" +) + +type Config struct { + Name string `json:"name"` + Host string `json:"host"` + Port int `json:"port"` + Debug bool `json:"debug"` + + Database struct { + Host string `json:"host"` + Port int `json:"port"` + Username string `json:"username"` + Password string `json:"password"` + } `json:"database"` + + Config string `confless:"file,format=yaml"` +} + +func main() { + // Define command-line flags + flag.String("host", "", "Server host") + flag.Int("port", 0, "Server port") + flag.Bool("debug", false, "Enable debug mode") + flag.String("database-host", "", "Database host") + flag.Int("database-port", 0, "Database port") + flag.Bool("database-ssl", false, "Enable SSL for database") + flag.String("config", "", "Path to configuration file") + + // Set default values + config := &Config{ + Name: "default", + Host: "localhost", + Port: 8080, + Debug: false, + } + + // Read configuration from command-line flags + confless.RegisterFlags(flag.CommandLine) + // Read configuration from environment variables starting with "APP_" + confless.RegisterEnv("APP") + // Read configuration from config.json + confless.RegisterFile("files/config.json", confless.FileFormatJSON) + + // Parse flags before loading + flag.Parse() + + // Load configuration from all registered sources + err := confless.Load(config) + if err != nil { + log.Fatalf("Failed to load configuration: %v", err) + } + + // Print the final configuration to stdout + enc := json.NewEncoder(os.Stdout) + if err := enc.Encode(config); err != nil { + log.Fatalf("Failed to encode config as JSON: %v", err) + } +} diff --git a/loader.go b/loader.go index 48ff287..1d0148f 100644 --- a/loader.go +++ b/loader.go @@ -69,29 +69,8 @@ func (l *loader) RegisterFlags(f *flag.FlagSet) { // Populate the object by applying the registered sources. func (l *loader) Load(obj any) error { - files := make([]*configFile, 0, len(l.files)) - files = append(files, l.files...) - - // Add file paths from tagged fields. - for field, format := range findFileFields(obj) { - path := field.String() - if path == "" { - continue - } - - format := fileFormat(format) - if format == "" { - format = FileFormatJSON - } - - files = append(files, &configFile{ - path: path, - format: format, - }) - } - // Load the files. - for _, file := range files { + for _, file := range l.files { // Open the file. f, err := l.fs.Open(file.path) if err != nil { @@ -127,5 +106,36 @@ func (l *loader) Load(obj any) error { } } + // Load dynamically files. + for field, format := range findFileFields(obj) { + path := field.String() + if path == "" { + continue + } + + format := fileFormat(format) + if format == "" { + format = FileFormatJSON + } + + // Open the file. + f, err := l.fs.Open(path) + if err != nil { + if os.IsNotExist(err) { + // Skip if file does not exist. + continue + } + + return fmt.Errorf("failed to open file: %w", err) + } + + // Populate the object by the file. + err = populateByFile(f, string(format), obj) + _ = f.Close() + if err != nil { + return fmt.Errorf("failed to load file: %w", err) + } + } + return nil }