Skip to content

Commit ddea063

Browse files
committed
phase 2: struct tag parsing and env var lookup
1 parent dcf35b1 commit ddea063

3 files changed

Lines changed: 338 additions & 3 deletions

File tree

envstruct.go

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,99 @@
44
// Process(prefix, &spec) function signature.
55
package envstruct
66

7-
import "io"
7+
import (
8+
"fmt"
9+
"io"
10+
"os"
11+
"reflect"
12+
"strings"
13+
)
814

915
// Process populates the struct pointed to by spec with values from
1016
// environment variables. The prefix is prepended to each field name
1117
// (or tag override) when looking up environment variables.
1218
func Process(prefix string, spec interface{}) error {
19+
rv := reflect.ValueOf(spec)
20+
if rv.Kind() != reflect.Ptr || rv.IsNil() {
21+
return fmt.Errorf("envstruct: spec must be a non-nil pointer to a struct")
22+
}
23+
rv = rv.Elem()
24+
if rv.Kind() != reflect.Struct {
25+
return fmt.Errorf("envstruct: spec must be a pointer to a struct")
26+
}
27+
return processStruct(prefix, rv)
28+
}
29+
30+
func processStruct(prefix string, rv reflect.Value) error {
31+
rt := rv.Type()
32+
for i := 0; i < rt.NumField(); i++ {
33+
f := rt.Field(i)
34+
fv := rv.Field(i)
35+
36+
// Skip unexported fields.
37+
if !f.IsExported() {
38+
continue
39+
}
40+
41+
// Build the env var name component from the field name.
42+
envName := strings.ToUpper(f.Name)
43+
spec := parseTag(f, envName)
44+
if spec.Ignored {
45+
continue
46+
}
47+
48+
// Build full env var key.
49+
key := spec.Name
50+
if prefix != "" {
51+
key = prefix + "_" + spec.Name
52+
}
53+
key = strings.ToUpper(key)
54+
55+
// Look up value.
56+
val, found := os.LookupEnv(key)
57+
58+
if !found {
59+
if spec.HasDefault {
60+
val = spec.DefaultValue
61+
found = true
62+
} else if spec.Required {
63+
return &RequiredError{FieldName: f.Name, EnvVar: key}
64+
}
65+
}
66+
67+
if !found {
68+
continue
69+
}
70+
71+
// Set the field value (string-only for now, decoders come in Phase 3).
72+
if err := setField(fv, val, f.Name, key); err != nil {
73+
return err
74+
}
75+
}
76+
return nil
77+
}
78+
79+
func setField(fv reflect.Value, val string, fieldName string, envVar string) error {
80+
// Handle pointer fields: allocate if nil.
81+
if fv.Kind() == reflect.Ptr {
82+
if fv.IsNil() {
83+
fv.Set(reflect.New(fv.Type().Elem()))
84+
}
85+
fv = fv.Elem()
86+
}
87+
88+
switch fv.Kind() {
89+
case reflect.String:
90+
fv.SetString(val)
91+
default:
92+
return &ParseError{
93+
FieldName: fieldName,
94+
EnvVar: envVar,
95+
Value: val,
96+
TypeName: fv.Type().String(),
97+
Err: fmt.Errorf("unsupported type (decoders not yet implemented)"),
98+
}
99+
}
13100
return nil
14101
}
15102

envstruct_test.go

Lines changed: 207 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,218 @@
11
package envstruct
22

3-
import "testing"
3+
import (
4+
"errors"
5+
"os"
6+
"testing"
7+
)
48

5-
func TestProcessNil(t *testing.T) {
9+
func setEnv(t *testing.T, key, value string) {
10+
t.Helper()
11+
t.Setenv(key, value)
12+
}
13+
14+
func TestProcessBasicString(t *testing.T) {
15+
type Config struct {
16+
Host string
17+
}
18+
setEnv(t, "APP_HOST", "localhost")
19+
var c Config
20+
if err := Process("APP", &c); err != nil {
21+
t.Fatalf("unexpected error: %v", err)
22+
}
23+
if c.Host != "localhost" {
24+
t.Fatalf("expected 'localhost', got %q", c.Host)
25+
}
26+
}
27+
28+
func TestProcessNoPrefix(t *testing.T) {
629
type Config struct {
730
Host string
831
}
32+
setEnv(t, "HOST", "example.com")
933
var c Config
1034
if err := Process("", &c); err != nil {
1135
t.Fatalf("unexpected error: %v", err)
1236
}
37+
if c.Host != "example.com" {
38+
t.Fatalf("expected 'example.com', got %q", c.Host)
39+
}
40+
}
41+
42+
func TestProcessEnvTag(t *testing.T) {
43+
type Config struct {
44+
Host string `env:"CUSTOM_HOST"`
45+
}
46+
setEnv(t, "APP_CUSTOM_HOST", "tagged.com")
47+
var c Config
48+
if err := Process("APP", &c); err != nil {
49+
t.Fatalf("unexpected error: %v", err)
50+
}
51+
if c.Host != "tagged.com" {
52+
t.Fatalf("expected 'tagged.com', got %q", c.Host)
53+
}
54+
}
55+
56+
func TestProcessEnvconfigTag(t *testing.T) {
57+
type Config struct {
58+
Host string `envconfig:"MY_HOST"`
59+
}
60+
setEnv(t, "APP_MY_HOST", "compat.com")
61+
var c Config
62+
if err := Process("APP", &c); err != nil {
63+
t.Fatalf("unexpected error: %v", err)
64+
}
65+
if c.Host != "compat.com" {
66+
t.Fatalf("expected 'compat.com', got %q", c.Host)
67+
}
68+
}
69+
70+
func TestProcessRequired(t *testing.T) {
71+
type Config struct {
72+
Host string `env:"HOST,required"`
73+
}
74+
var c Config
75+
err := Process("APP", &c)
76+
if err == nil {
77+
t.Fatal("expected error for missing required field")
78+
}
79+
var reqErr *RequiredError
80+
if !errors.As(err, &reqErr) {
81+
t.Fatalf("expected RequiredError, got %T: %v", err, err)
82+
}
83+
if reqErr.FieldName != "Host" {
84+
t.Fatalf("expected field 'Host', got %q", reqErr.FieldName)
85+
}
86+
}
87+
88+
func TestProcessRequiredPresent(t *testing.T) {
89+
type Config struct {
90+
Host string `env:"HOST,required"`
91+
}
92+
setEnv(t, "APP_HOST", "present.com")
93+
var c Config
94+
if err := Process("APP", &c); err != nil {
95+
t.Fatalf("unexpected error: %v", err)
96+
}
97+
if c.Host != "present.com" {
98+
t.Fatalf("expected 'present.com', got %q", c.Host)
99+
}
100+
}
101+
102+
func TestProcessDefault(t *testing.T) {
103+
type Config struct {
104+
Host string `default:"default.com"`
105+
}
106+
var c Config
107+
if err := Process("APP", &c); err != nil {
108+
t.Fatalf("unexpected error: %v", err)
109+
}
110+
if c.Host != "default.com" {
111+
t.Fatalf("expected 'default.com', got %q", c.Host)
112+
}
113+
}
114+
115+
func TestProcessDefaultOverriddenByEnv(t *testing.T) {
116+
type Config struct {
117+
Host string `default:"default.com"`
118+
}
119+
setEnv(t, "APP_HOST", "override.com")
120+
var c Config
121+
if err := Process("APP", &c); err != nil {
122+
t.Fatalf("unexpected error: %v", err)
123+
}
124+
if c.Host != "override.com" {
125+
t.Fatalf("expected 'override.com', got %q", c.Host)
126+
}
127+
}
128+
129+
func TestProcessIgnored(t *testing.T) {
130+
type Config struct {
131+
Host string
132+
Secret string `env:"-"`
133+
}
134+
setEnv(t, "APP_HOST", "host.com")
135+
os.Setenv("APP_SECRET", "should-be-ignored")
136+
defer os.Unsetenv("APP_SECRET")
137+
138+
var c Config
139+
if err := Process("APP", &c); err != nil {
140+
t.Fatalf("unexpected error: %v", err)
141+
}
142+
if c.Secret != "" {
143+
t.Fatalf("expected empty string for ignored field, got %q", c.Secret)
144+
}
145+
}
146+
147+
func TestProcessUnexportedFieldSkipped(t *testing.T) {
148+
type Config struct {
149+
Host string
150+
secret string //nolint:unused
151+
}
152+
setEnv(t, "APP_HOST", "host.com")
153+
var c Config
154+
if err := Process("APP", &c); err != nil {
155+
t.Fatalf("unexpected error: %v", err)
156+
}
157+
if c.Host != "host.com" {
158+
t.Fatalf("expected 'host.com', got %q", c.Host)
159+
}
160+
}
161+
162+
func TestProcessNonPointer(t *testing.T) {
163+
type Config struct {
164+
Host string
165+
}
166+
var c Config
167+
err := Process("APP", c)
168+
if err == nil {
169+
t.Fatal("expected error for non-pointer")
170+
}
171+
}
172+
173+
func TestProcessNilPointer(t *testing.T) {
174+
err := Process("APP", (*struct{})(nil))
175+
if err == nil {
176+
t.Fatal("expected error for nil pointer")
177+
}
178+
}
179+
180+
func TestProcessPointerField(t *testing.T) {
181+
type Config struct {
182+
Host *string
183+
}
184+
setEnv(t, "APP_HOST", "ptr.com")
185+
var c Config
186+
if err := Process("APP", &c); err != nil {
187+
t.Fatalf("unexpected error: %v", err)
188+
}
189+
if c.Host == nil || *c.Host != "ptr.com" {
190+
t.Fatalf("expected pointer to 'ptr.com', got %v", c.Host)
191+
}
192+
}
193+
194+
func TestProcessPointerFieldUnset(t *testing.T) {
195+
type Config struct {
196+
Host *string
197+
}
198+
var c Config
199+
if err := Process("APP", &c); err != nil {
200+
t.Fatalf("unexpected error: %v", err)
201+
}
202+
if c.Host != nil {
203+
t.Fatalf("expected nil pointer, got %v", c.Host)
204+
}
205+
}
206+
207+
func TestMustProcessPanics(t *testing.T) {
208+
type Config struct {
209+
Host string `env:"HOST,required"`
210+
}
211+
defer func() {
212+
if r := recover(); r == nil {
213+
t.Fatal("expected panic")
214+
}
215+
}()
216+
var c Config
217+
MustProcess("APP", &c)
13218
}

tags.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
package envstruct
22

3+
import (
4+
"reflect"
5+
"strings"
6+
)
7+
38
// fieldSpec holds parsed struct tag information for a single field.
49
type fieldSpec struct {
510
Name string
@@ -8,3 +13,41 @@ type fieldSpec struct {
813
HasDefault bool
914
Ignored bool
1015
}
16+
17+
// parseTag extracts a fieldSpec from a struct field's tags.
18+
// It checks env, then envconfig tags (for migration compatibility).
19+
// The fieldName is the struct field name used as a fallback.
20+
func parseTag(f reflect.StructField, fieldName string) fieldSpec {
21+
spec := fieldSpec{Name: fieldName}
22+
23+
// Check env tag first, then envconfig for compat.
24+
tag, ok := f.Tag.Lookup("env")
25+
if !ok {
26+
tag, ok = f.Tag.Lookup("envconfig")
27+
}
28+
29+
if ok {
30+
parts := strings.Split(tag, ",")
31+
name := parts[0]
32+
if name == "-" {
33+
spec.Ignored = true
34+
return spec
35+
}
36+
if name != "" {
37+
spec.Name = name
38+
}
39+
for _, opt := range parts[1:] {
40+
if opt == "required" {
41+
spec.Required = true
42+
}
43+
}
44+
}
45+
46+
// Check default tag.
47+
if defVal, ok := f.Tag.Lookup("default"); ok {
48+
spec.DefaultValue = defVal
49+
spec.HasDefault = true
50+
}
51+
52+
return spec
53+
}

0 commit comments

Comments
 (0)