Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,38 @@ This is not allowed:
Level string `validate:"required" default:"warn"` // will result in an error
}

# FileReader

Custom file readers can be used to preprocess configuration files before they are parsed by fig's decoders. This is useful for scenarios like environment variable substitution, template processing, or custom file formats.

type Config struct {
Host string `fig:"host"`
Port int `fig:"port"`
}

// Custom file reader that expands environment variables
fileReader := func(filePath string) (io.Reader, error) {
file, err := os.Open(filePath)
if err != nil {
return nil, err
}
defer file.Close()

content, err := io.ReadAll(file)
if err != nil {
return nil, err
}

// Expand environment variables in the content
expanded := os.ExpandEnv(string(content))
return strings.NewReader(expanded), nil
}

var cfg Config
err := fig.Load(&cfg, fig.WithFileReader(fileReader))

The file reader function receives the file path and must return an `io.Reader`. If the returned reader also implements `io.Closer`, fig will automatically close it after reading.

# Errors

A wrapped error `ErrFileNotFound` is returned when fig is not able to find a config file to load. This can be useful for instance to fallback to a different configuration loading mechanism.
Expand Down
18 changes: 16 additions & 2 deletions fig.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"reflect"
Expand Down Expand Up @@ -70,6 +71,11 @@ type StringUnmarshaler interface {
UnmarshalString(s string) error
}

// FileReader is a function type that provides a custom way to read configuration files.
// It enables preprocessing of configuration files before they are parsed by fig's decoders.
// If the returned io.Reader implements io.Closer, fig will automatically close it.
type FileReader func(filePath string) (io.Reader, error)

// Load reads a configuration file and loads it into the given struct. The
// parameter `cfg` must be a pointer to a struct.
//
Expand Down Expand Up @@ -109,6 +115,7 @@ func defaultFig() *fig {
dirs: []string{DefaultDir},
tag: DefaultTag,
timeLayout: DefaultTimeLayout,
fileReader: defaultFileReader,
}
}

Expand All @@ -122,6 +129,7 @@ type fig struct {
ignoreFile bool
allowNoFile bool
envPrefix string
fileReader FileReader
}

func (f *fig) Load(cfg interface{}) error {
Expand Down Expand Up @@ -174,11 +182,13 @@ func (f *fig) findCfgFile() (path string, err error) {

// decodeFile reads the file and unmarshalls it using a decoder based on the file extension.
func (f *fig) decodeFile(file string) (map[string]interface{}, error) {
fd, err := os.Open(file)
fd, err := f.fileReader(file)
if err != nil {
return nil, err
}
defer fd.Close()
if closer, ok := fd.(io.Closer); ok {
defer closer.Close()
}

vals := make(map[string]interface{})

Expand Down Expand Up @@ -437,6 +447,10 @@ func (f *fig) setSlice(sv reflect.Value, val string) error {
return nil
}

func defaultFileReader(filePath string) (io.Reader, error) {
return os.Open(filePath)
}

// trySetFromStringUnmarshaler takes a value fv which is expected to implement the
// StringUnmarshaler interface and attempts to unmarshal the string val into the field.
// If the value does not implement the interface, or an error occurs during the unmarshal,
Expand Down
71 changes: 71 additions & 0 deletions fig_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package fig
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"reflect"
Expand Down Expand Up @@ -1314,6 +1315,76 @@ func Test_fig_setSlice(t *testing.T) {
})
}

func Test_fig_Load_WithFileReader(t *testing.T) {
t.Run("custom file reader with preprocessing", func(t *testing.T) {
type Config struct {
Host string `fig:"host"`
Logger struct {
LogLevel string `fig:"log_level"`
} `fig:"logger"`
}

// CustomFileReader simulates file preprocessing
customReader := func(filePath string) (io.Reader, error) {
if filePath != "testdata/valid/server.yaml" {
t.Fatalf("unexpected file path: got %s, want %s", filePath, "testdata/valid/server.yaml")
}
// Simulates preprocessing - replaces original content
content := `host: "127.0.0.1"
logger:
log_level: "info"`
return strings.NewReader(content), nil
}

var cfg Config
err := Load(&cfg, WithFileReader(customReader), File("server.yaml"), Dirs("testdata/valid"))
if err != nil {
t.Fatalf("unexpected err: %v", err)
}

want := Config{
Host: "127.0.0.1",
Logger: struct {
LogLevel string `fig:"log_level"`
}{
LogLevel: "info",
},
}

if !reflect.DeepEqual(want, cfg) {
t.Errorf("\nwant %+v\ngot %+v", want, cfg)
}
})

t.Run("file reader with closer", func(t *testing.T) {
type Config struct {
Host string `fig:"host"`
Logger struct {
LogLevel string `fig:"log_level"`
} `fig:"logger"`
}

// FileReader that returns a real file (implements io.Closer)
fileReader := func(filePath string) (io.Reader, error) {
return os.Open(filePath)
}

var cfg Config
err := Load(&cfg, WithFileReader(fileReader), File("server.yaml"), Dirs("testdata/valid"))
if err != nil {
t.Fatalf("unexpected err: %v", err)
}

// Verifies that real file data was loaded
if cfg.Host != "0.0.0.0" {
t.Errorf("expected host '0.0.0.0', got %s", cfg.Host)
}
if cfg.Logger.LogLevel != "debug" {
t.Errorf("expected logger.log_level 'debug', got %s", cfg.Logger.LogLevel)
}
})
}

func setenv(t *testing.T, key, value string) {
t.Helper()
t.Setenv(key, value)
Expand Down
35 changes: 35 additions & 0 deletions option.go
Original file line number Diff line number Diff line change
Expand Up @@ -132,3 +132,38 @@ func AllowNoFile() Option {
f.allowNoFile = true
}
}

// WithFileReader returns an option that configures a custom file reader for
// preprocessing configuration files before they are parsed by fig's decoders.
//
// This is useful for scenarios like environment variable substitution, template
// processing, or custom file formats. The file reader function receives the
// file path and must return an io.Reader. If the returned reader also
// implements io.Closer, fig will automatically close it after reading.
//
// fileReader := func(filePath string) (io.Reader, error) {
// file, err := os.Open(filePath)
// if err != nil {
// return nil, err
// }
// defer file.Close()
//
// content, err := io.ReadAll(file)
// if err != nil {
// return nil, err
// }
//
// // Expand environment variables in the content
// expanded := os.ExpandEnv(string(content))
// return strings.NewReader(expanded), nil
// }
//
// fig.Load(&cfg, fig.WithFileReader(fileReader))
//
// If this option is not used then fig uses the default file reader which
// opens files directly with os.Open.
func WithFileReader(fileReader FileReader) Option {
return func(f *fig) {
f.fileReader = fileReader
}
}