|
1 | 1 | package config |
2 | 2 |
|
3 | 3 | import ( |
| 4 | + "encoding/json" |
4 | 5 | "errors" |
5 | 6 | "fmt" |
6 | 7 | "os" |
| 8 | + "strings" |
7 | 9 |
|
8 | | - "github.com/joho/godotenv" |
9 | | - "github.com/kelseyhightower/envconfig" |
| 10 | + "github.com/knadh/koanf/parsers/dotenv" |
| 11 | + "github.com/knadh/koanf/parsers/yaml" |
| 12 | + "github.com/knadh/koanf/providers/env/v2" |
| 13 | + "github.com/knadh/koanf/providers/file" |
| 14 | + "github.com/knadh/koanf/providers/s3" |
| 15 | + "github.com/knadh/koanf/v2" |
10 | 16 | ) |
11 | 17 |
|
12 | | -func Load[T any](c *T) error { |
13 | | - err := godotenv.Load() |
| 18 | +var ErrMissingAWSCreds = errors.New("AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION must be set") |
| 19 | + |
| 20 | +// Load reads configuration from various sources and unmarshals it into a given struct. |
| 21 | +// |
| 22 | +// It looks for configuration in the following order (later overrides earlier): |
| 23 | +// 1. S3, if `WithS3YAML` is provided. |
| 24 | +// 2. Local file, if `WithLocalYAML` is provided. |
| 25 | +// 3. `.env` file in the current working directory. |
| 26 | +// 4. Environment variables. |
| 27 | +// |
| 28 | +// If any of the above sources result in an error (other than `os.ErrNotExist`), it will be returned. |
| 29 | +// |
| 30 | +// If a source results in `os.ErrNotExist`, it will be skipped. |
| 31 | +// |
| 32 | +// The final configuration will be unmarshaled into the given struct. If unmarshaling fails, an error will be returned. |
| 33 | +func Load[T any](c *T, opts ...Option) error { |
| 34 | + options := new(options) |
| 35 | + options.apply(opts...) |
| 36 | + |
| 37 | + k := koanf.New(".") |
| 38 | + |
| 39 | + if err := loadFromS3(options, k); err != nil { |
| 40 | + return err |
| 41 | + } |
| 42 | + |
| 43 | + if err := loadFromYAML(options.withYaml, k); err != nil { |
| 44 | + return err |
| 45 | + } |
| 46 | + |
| 47 | + if err := loadDotenv(k); err != nil { |
| 48 | + return err |
| 49 | + } |
| 50 | + |
| 51 | + if err := loadEnv(k); err != nil { |
| 52 | + return err |
| 53 | + } |
| 54 | + |
| 55 | + if err := k.Unmarshal("", c); err != nil { |
| 56 | + return fmt.Errorf("unmarshal: %w", err) |
| 57 | + } |
| 58 | + |
| 59 | + return nil |
| 60 | +} |
| 61 | + |
| 62 | +func loadFromS3(options *options, k *koanf.Koanf) error { |
| 63 | + if options.withS3Bucket == "" || options.withS3ObjectKey == "" { |
| 64 | + return nil |
| 65 | + } |
| 66 | + |
| 67 | + accessKey := os.Getenv("AWS_ACCESS_KEY_ID") |
| 68 | + secretKey := os.Getenv("AWS_SECRET_ACCESS_KEY") |
| 69 | + region := os.Getenv("AWS_REGION") |
| 70 | + endpoint := os.Getenv("AWS_ENDPOINT") |
| 71 | + |
| 72 | + if accessKey == "" || secretKey == "" || region == "" { |
| 73 | + return ErrMissingAWSCreds |
| 74 | + } |
| 75 | + |
| 76 | + s3Config := s3.Config{ |
| 77 | + AccessKey: accessKey, |
| 78 | + SecretKey: secretKey, |
| 79 | + Region: region, |
| 80 | + Bucket: options.withS3Bucket, |
| 81 | + ObjectKey: options.withS3ObjectKey, |
| 82 | + Endpoint: endpoint, |
| 83 | + } |
| 84 | + |
| 85 | + err := k.Load(s3.Provider(s3Config), yaml.Parser()) |
| 86 | + if err != nil && !strings.Contains(err.Error(), "404") { |
| 87 | + return fmt.Errorf("load s3: %w", err) |
| 88 | + } |
| 89 | + |
| 90 | + return nil |
| 91 | +} |
| 92 | + |
| 93 | +func loadFromYAML(path string, k *koanf.Koanf) error { |
| 94 | + if path == "" { |
| 95 | + return nil |
| 96 | + } |
| 97 | + |
| 98 | + err := k.Load(file.Provider(path), yaml.Parser()) |
14 | 99 | if err != nil && !errors.Is(err, os.ErrNotExist) { |
15 | | - return fmt.Errorf("failed to load .env file: %w", err) |
| 100 | + return fmt.Errorf("load yaml: %w", err) |
16 | 101 | } |
17 | 102 |
|
18 | | - return loadFromEnv(c) |
| 103 | + return nil |
19 | 104 | } |
20 | 105 |
|
21 | | -func loadFromEnv[T any](c *T) error { |
22 | | - if err := envconfig.Process("", c); err != nil { |
23 | | - return fmt.Errorf("failed to load envconfig: %w", err) |
| 106 | +func loadDotenv(k *koanf.Koanf) error { |
| 107 | + err := k.Load(file.Provider(".env"), dotenv.ParserEnvWithValue("", "__", envTransform)) |
| 108 | + if err != nil && !errors.Is(err, os.ErrNotExist) { |
| 109 | + return fmt.Errorf("load dotenv: %w", err) |
24 | 110 | } |
25 | 111 |
|
26 | 112 | return nil |
27 | 113 | } |
| 114 | + |
| 115 | +func loadEnv(k *koanf.Koanf) error { |
| 116 | + if err := k.Load(env.Provider("__", env.Opt{ |
| 117 | + Prefix: "", |
| 118 | + TransformFunc: envTransform, |
| 119 | + EnvironFunc: nil, |
| 120 | + }), nil); err != nil { |
| 121 | + return fmt.Errorf("load env: %w", err) |
| 122 | + } |
| 123 | + |
| 124 | + return nil |
| 125 | +} |
| 126 | + |
| 127 | +func envTransform(k, v string) (string, any) { |
| 128 | + k = strings.ToLower(k) |
| 129 | + // JSON object -> map |
| 130 | + if strings.HasPrefix(v, "{") && strings.HasSuffix(v, "}") { |
| 131 | + var m map[string]any |
| 132 | + if err := json.Unmarshal([]byte(v), &m); err == nil { |
| 133 | + return k, m |
| 134 | + } |
| 135 | + } |
| 136 | + // JSON array -> []any |
| 137 | + if strings.HasPrefix(v, "[") && strings.HasSuffix(v, "]") { |
| 138 | + var a []any |
| 139 | + if err := json.Unmarshal([]byte(v), &a); err == nil { |
| 140 | + return k, a |
| 141 | + } |
| 142 | + } |
| 143 | + // CSV -> []string |
| 144 | + if strings.Contains(v, ",") { |
| 145 | + parts := strings.Split(v, ",") |
| 146 | + for i := range parts { |
| 147 | + parts[i] = strings.TrimSpace(parts[i]) |
| 148 | + } |
| 149 | + return k, parts |
| 150 | + } |
| 151 | + return k, v |
| 152 | +} |
0 commit comments