Skip to content

Commit 8cab989

Browse files
committed
2010 | Use a Library for Reading Configuration from Environment Variables
Signed-off-by: Nandhukumar <nandhukumare@gmail.com>
1 parent 4c76df4 commit 8cab989

15 files changed

Lines changed: 644 additions & 173 deletions

File tree

esignet-service/.env.example

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@
44
# Copy this file to .env and adjust values for your environment:
55
# cp .env.example .env
66
#
7-
# make.sh loads .env automatically when present.
8-
# Precedence: VAR=VALUE on the command line > .env > shell environment > defaults.
7+
# The service loads .env from the working directory at startup (via godotenv),
8+
# so these values apply to `go run`, the binary, Docker, and tests. make.sh also
9+
# loads .env for shell-based runs. Variables already set in the real environment
10+
# always take precedence over .env.
911
# ─────────────────────────────────────────────────────────────────────────────
1012

1113
# ── HTTP / ThunderID engine ───────────────────────────────────────────────────

esignet-service/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,8 @@ cp .env.example .env # fill in DATABASE_* / DB_DBUSER_PASSWORD and REDI
6666

6767
Copy `.env.example` to `.env` to override defaults, or pass overrides on the command line (`./make.sh run PORT=9090`).
6868

69+
The service loads `.env` from the working directory itself at startup (via [godotenv](https://github.com/joho/godotenv)), so it applies to `go run`, the built binary, Docker, and tests alike — not just `make.sh`. Real environment variables already set always take precedence over `.env`.
70+
6971
### Binary
7072

7173
```bash

esignet-service/cmd/esignet/main.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ import (
1414
)
1515

1616
func main() {
17+
// Load .env (if present) before the logger initialises, so LOG_LEVEL from
18+
// .env is honoured and all subsequent config.Load* calls see its values.
19+
config.Init()
20+
1721
logger := applog.GetLogger()
1822
appCfg := config.LoadAppConfig()
1923

esignet-service/go.mod

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ go 1.26
44

55
require (
66
github.com/golang-jwt/jwt/v5 v5.3.0
7+
github.com/joho/godotenv v1.5.1
8+
github.com/kelseyhightower/envconfig v1.4.0
79
github.com/lib/pq v1.10.9
810
github.com/redis/go-redis/v9 v9.18.0
911
github.com/stretchr/testify v1.11.1

esignet-service/go.sum

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
4545
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
4646
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 h1:NmZ1PKzSTQbuGHw9DGPFomqkkLWMC+vZCkfs+FHv1Vg=
4747
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3/go.mod h1:zQrxl1YP88HQlA6i9c63DSVPFklWpGX4OWAc9bFuaH4=
48+
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
49+
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
50+
github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8=
51+
github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg=
4852
github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4=
4953
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
5054
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=

esignet-service/internal/config/app.go

Lines changed: 21 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,8 @@ package config
33

44
import (
55
"fmt"
6-
"os"
7-
"strconv"
8-
"strings"
9-
)
106

11-
const (
12-
defaultPort = "8088"
13-
defaultDataDir = "./data"
7+
"github.com/mosip/esignet/internal/config/envvar"
148
)
159

1610
// AppConfig holds core HTTP and infrastructure settings for the service.
@@ -22,42 +16,30 @@ type AppConfig struct {
2216
Redis Redis
2317
}
2418

25-
// LoadAppConfig reads application settings from the environment.
26-
func LoadAppConfig() AppConfig {
27-
port := envOrDefault("PORT", defaultPort)
28-
return AppConfig{
29-
Port: port,
30-
Issuer: envOrDefault("MOSIP_ESIGNET_HOST", fmt.Sprintf("http://127.0.0.1:%s", port)),
31-
DataDir: envOrDefault("DATA_DIR", defaultDataDir),
32-
DB: loadDB(),
33-
Redis: loadRedis(),
34-
}
19+
// appSpec is the environment-variable layout for core application settings.
20+
// Issuer carries no default tag because its fallback is derived from the
21+
// resolved Port at load time.
22+
type appSpec struct {
23+
Port string `envconfig:"PORT" default:"8088"`
24+
Issuer string `envconfig:"MOSIP_ESIGNET_HOST"`
25+
DataDir string `envconfig:"DATA_DIR" default:"./data"`
3526
}
3627

37-
func envOrDefault(key, fallback string) string {
38-
if value := os.Getenv(key); value != "" {
39-
return value
40-
}
41-
return fallback
42-
}
28+
// LoadAppConfig reads application settings from the environment.
29+
func LoadAppConfig() AppConfig {
30+
var s appSpec
31+
envvar.Process(&s)
4332

44-
func envInt(key string) int {
45-
raw := os.Getenv(key)
46-
if raw == "" {
47-
return 0
48-
}
49-
n, err := strconv.Atoi(raw)
50-
if err != nil {
51-
return 0
33+
issuer := s.Issuer
34+
if issuer == "" {
35+
issuer = fmt.Sprintf("http://127.0.0.1:%s", s.Port)
5236
}
53-
return n
54-
}
5537

56-
func envBool(key string) bool {
57-
switch strings.ToLower(strings.TrimSpace(os.Getenv(key))) {
58-
case "1", "true", "yes", "on":
59-
return true
60-
default:
61-
return false
38+
return AppConfig{
39+
Port: s.Port,
40+
Issuer: issuer,
41+
DataDir: s.DataDir,
42+
DB: loadDB(),
43+
Redis: loadRedis(),
6244
}
6345
}

esignet-service/internal/config/db.go

Lines changed: 36 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,12 @@ import (
44
"context"
55
"database/sql"
66
"fmt"
7-
"os"
87
"strings"
98
"time"
109

1110
_ "github.com/lib/pq" // PostgreSQL driver for database/sql
11+
12+
"github.com/mosip/esignet/internal/config/envvar"
1213
)
1314

1415
const (
@@ -33,18 +34,38 @@ type DB struct {
3334
Pool DBPool
3435
}
3536

37+
// dbSpec is the environment-variable layout for Postgres settings. The
38+
// individual connection params are only used to build the DSN when POSTGRES_URL
39+
// is empty, but always carry their defaults.
40+
type dbSpec struct {
41+
URL string `envconfig:"POSTGRES_URL"`
42+
Host string `envconfig:"DATABASE_HOST" default:"localhost"`
43+
Port string `envconfig:"DATABASE_PORT" default:"5432"`
44+
Name string `envconfig:"DATABASE_NAME" default:"mosip_esignet"`
45+
User string `envconfig:"DATABASE_USERNAME" default:"postgres"`
46+
Password string `envconfig:"DB_DBUSER_PASSWORD"`
47+
48+
MaxOpenConns envvar.LaxInt `envconfig:"DB_MAX_OPEN_CONNS"`
49+
MaxIdleConns envvar.LaxInt `envconfig:"DB_MAX_IDLE_CONNS"`
50+
ConnMaxLifetime envvar.SecondsDuration `envconfig:"DB_CONN_MAX_LIFETIME_SECS"`
51+
ConnMaxIdleTime envvar.SecondsDuration `envconfig:"DB_CONN_MAX_IDLE_TIME_SECS"`
52+
}
53+
3654
// loadDB reads Postgres connection config and pool settings from the environment.
3755
// Accepts either POSTGRES_URL (full DSN) or individual vars:
38-
// POSTGRES_HOST, POSTGRES_PORT, POSTGRES_DB, POSTGRES_USER, POSTGRES_PASSWORD.
56+
// DATABASE_HOST, DATABASE_PORT, DATABASE_NAME, DATABASE_USERNAME, DB_DBUSER_PASSWORD.
3957
//
4058
// Pool tuning (all optional):
4159
//
42-
// DB_MAX_OPEN_CONNS — default 25
43-
// DB_MAX_IDLE_CONNS — default 5
44-
// DB_CONN_MAX_LIFETIME_SECS — default 300
60+
// DB_MAX_OPEN_CONNS — default 25
61+
// DB_MAX_IDLE_CONNS — default 5
62+
// DB_CONN_MAX_LIFETIME_SECS — default 300
4563
// DB_CONN_MAX_IDLE_TIME_SECS — default 60
4664
func loadDB() DB {
47-
dsn := os.Getenv("POSTGRES_URL")
65+
var s dbSpec
66+
envvar.Process(&s)
67+
68+
dsn := s.URL
4869
if dsn != "" &&
4970
(strings.HasPrefix(dsn, "postgres://") || strings.HasPrefix(dsn, "postgresql://")) &&
5071
!strings.Contains(strings.ToLower(dsn), "sslmode=") {
@@ -55,46 +76,35 @@ func loadDB() DB {
5576
dsn += sep + "sslmode=disable"
5677
}
5778
if dsn == "" {
58-
host := envOrDefault("DATABASE_HOST", "localhost")
59-
port := envOrDefault("DATABASE_PORT", "5432")
60-
dbname := envOrDefault("DATABASE_NAME", "mosip_esignet")
61-
user := envOrDefault("DATABASE_USERNAME", "postgres")
62-
password := os.Getenv("DB_DBUSER_PASSWORD")
63-
if password != "" {
79+
if s.Password != "" {
6480
dsn = fmt.Sprintf(
6581
"host=%s port=%s dbname=%s user=%s password=%s sslmode=disable",
66-
host, port, dbname, user, password,
82+
s.Host, s.Port, s.Name, s.User, s.Password,
6783
)
6884
} else {
6985
// Omit password= when unset — lib/pq mis-parses "password= sslmode=..."
7086
// and falls back to SSL, which fails against local Docker Postgres.
7187
dsn = fmt.Sprintf(
7288
"host=%s port=%s dbname=%s user=%s sslmode=disable",
73-
host, port, dbname, user,
89+
s.Host, s.Port, s.Name, s.User,
7490
)
7591
}
7692
}
7793

78-
maxOpen := envInt("DB_MAX_OPEN_CONNS")
94+
maxOpen := int(s.MaxOpenConns)
7995
if maxOpen <= 0 {
8096
maxOpen = defaultDBMaxOpenConns
8197
}
82-
maxIdle := envInt("DB_MAX_IDLE_CONNS")
98+
maxIdle := int(s.MaxIdleConns)
8399
if maxIdle <= 0 {
84100
maxIdle = defaultDBMaxIdleConns
85101
}
86-
lifetimeSecs := envInt("DB_CONN_MAX_LIFETIME_SECS")
87-
var lifetime time.Duration
88-
if lifetimeSecs > 0 {
89-
lifetime = time.Duration(lifetimeSecs) * time.Second
90-
} else {
102+
lifetime := time.Duration(s.ConnMaxLifetime)
103+
if lifetime <= 0 {
91104
lifetime = defaultDBConnMaxLifetime
92105
}
93-
idleSecs := envInt("DB_CONN_MAX_IDLE_TIME_SECS")
94-
var idleTime time.Duration
95-
if idleSecs > 0 {
96-
idleTime = time.Duration(idleSecs) * time.Second
97-
} else {
106+
idleTime := time.Duration(s.ConnMaxIdleTime)
107+
if idleTime <= 0 {
98108
idleTime = defaultDBConnMaxIdleTime
99109
}
100110

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
// Package envvar provides declarative, non-fatal parsing of configuration from
2+
// environment variables on top of kelseyhightower/envconfig.
3+
//
4+
// The decoder types here implement envconfig.Decoder so that configuration is
5+
// parsed via struct tags while preserving the lenient, non-fatal parsing
6+
// behaviour the service relied on before adopting envconfig: an unparseable
7+
// value logs a warning and falls back to the zero value rather than failing
8+
// startup. Callers apply their own business defaults to the zero value via the
9+
// historical "<= 0" / empty-string guards.
10+
//
11+
// It lives in its own package so the central config package and the per-engine
12+
// packages (mosip, sunbird) can all share the same parsing helpers.
13+
package envvar
14+
15+
import (
16+
"reflect"
17+
"strconv"
18+
"time"
19+
20+
"github.com/kelseyhightower/envconfig"
21+
22+
applog "github.com/mosip/esignet/internal/log"
23+
)
24+
25+
// LaxBool is a bool that accepts the historical set of spellings
26+
// (1/true/TRUE/yes/YES → true; 0/false/FALSE/no/NO → false) and warns +
27+
// defaults to false on anything else. It mirrors the former envBool helper.
28+
type LaxBool bool
29+
30+
func (b *LaxBool) Decode(value string) error {
31+
switch value {
32+
case "", "0", "false", "FALSE", "no", "NO":
33+
*b = false
34+
case "1", "true", "TRUE", "yes", "YES":
35+
*b = true
36+
default:
37+
applog.GetLogger().Warn(
38+
"invalid boolean environment variable value",
39+
applog.String("value", value),
40+
)
41+
*b = false
42+
}
43+
return nil
44+
}
45+
46+
// LaxInt is an int parsed with strconv.Atoi that warns + falls back to 0 on an
47+
// invalid value instead of failing. It mirrors the former envInt helper.
48+
type LaxInt int
49+
50+
func (i *LaxInt) Decode(value string) error {
51+
if value == "" {
52+
*i = 0
53+
return nil
54+
}
55+
parsed, err := strconv.Atoi(value)
56+
if err != nil {
57+
applog.GetLogger().Warn(
58+
"invalid integer environment variable value",
59+
applog.String("value", value),
60+
)
61+
*i = 0
62+
return nil
63+
}
64+
*i = LaxInt(parsed)
65+
return nil
66+
}
67+
68+
// LaxInt64 is an int64 parsed with strconv.ParseInt that warns + falls back to
69+
// 0 on an invalid value. It mirrors the former envInt64 helper.
70+
type LaxInt64 int64
71+
72+
func (i *LaxInt64) Decode(value string) error {
73+
if value == "" {
74+
*i = 0
75+
return nil
76+
}
77+
parsed, err := strconv.ParseInt(value, 10, 64)
78+
if err != nil {
79+
applog.GetLogger().Warn(
80+
"invalid integer environment variable value",
81+
applog.String("value", value),
82+
)
83+
*i = 0
84+
return nil
85+
}
86+
*i = LaxInt64(parsed)
87+
return nil
88+
}
89+
90+
// SecondsDuration is a time.Duration parsed from a plain integer number of
91+
// seconds — the historical *_SECS convention — rather than Go duration syntax.
92+
// Invalid values warn + fall back to 0; callers supply their own default when
93+
// 0 is not a meaningful value.
94+
type SecondsDuration time.Duration
95+
96+
func (d *SecondsDuration) Decode(value string) error {
97+
if value == "" {
98+
*d = 0
99+
return nil
100+
}
101+
secs, err := strconv.Atoi(value)
102+
if err != nil {
103+
applog.GetLogger().Warn(
104+
"invalid integer environment variable value",
105+
applog.String("value", value),
106+
)
107+
*d = 0
108+
return nil
109+
}
110+
*d = SecondsDuration(time.Duration(secs) * time.Second)
111+
return nil
112+
}
113+
114+
// Process populates spec from environment variables. Parsing of individual
115+
// fields never fails startup (see the Lax* decoders); a non-nil error here is
116+
// therefore unexpected and only logged.
117+
//
118+
// It also restores the historical "an empty value means use the default"
119+
// semantics: envconfig only applies a `default` tag when the variable is
120+
// entirely unset, whereas the previous envOrDefault helper also fell back to
121+
// the default when the variable was set to an empty string. applyStringDefaults
122+
// reinstates that behaviour for string fields.
123+
func Process(spec any) {
124+
if err := envconfig.Process("", spec); err != nil {
125+
applog.GetLogger().Warn("process configuration", applog.Error(err))
126+
}
127+
applyStringDefaults(spec)
128+
}
129+
130+
// applyStringDefaults fills any empty string field that carries a non-empty
131+
// `default` tag with that default.
132+
//
133+
// NOTE: this only walks top-level string fields. Every spec that uses this is a
134+
// flat struct, which keeps the walk simple; if a spec ever nests a sub-struct
135+
// with a defaulted string field, this must be made recursive or that field will
136+
// silently miss the empty-means-default behaviour.
137+
func applyStringDefaults(spec any) {
138+
v := reflect.ValueOf(spec).Elem()
139+
t := v.Type()
140+
for i := 0; i < t.NumField(); i++ {
141+
field := v.Field(i)
142+
if field.Kind() != reflect.String || !field.CanSet() {
143+
continue
144+
}
145+
def := t.Field(i).Tag.Get("default")
146+
if def != "" && field.String() == "" {
147+
field.SetString(def)
148+
}
149+
}
150+
}

0 commit comments

Comments
 (0)