Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Introduce more types and add context so we can slog from said ctx #1

Merged
merged 1 commit into from
Oct 2, 2024
Merged
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
25 changes: 12 additions & 13 deletions .github/workflows/go.yml
Original file line number Diff line number Diff line change
@@ -1,28 +1,27 @@
# This workflow will build a golang project
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go

name: Go
name: Build & Test

on:
push:
branches: [ "main" ]
branches: ["main"]
pull_request:
branches: [ "main" ]
branches: ["main"]

jobs:

build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4

- name: Set up Go
uses: actions/setup-go@v3
with:
go-version: 1.19
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: 1.22

- name: Build
run: go build -v ./...
- name: Build
run: go build -v ./...

- name: Test
run: go test -v ./...
- name: Test
run: go test -v ./...
29 changes: 17 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,14 @@ This aims to bridge that gap. It was created with the intention of allowing envi

### With flags.

Lets look at the default signature for a flag, here for `uint64`
Lets look at the default signature for a flag, here for `uint64`

```
func Uint64(name string, value uint64, usage string) *uint64
```

With `go-env` we can provide a value for, ahem, `value` that is a type checked env in a single function call (thanks generics!).

```go
import (
"os"
Expand All @@ -32,20 +34,22 @@ import (
)

os.Setenv("FIRST_FLAG_ENV_VAR", "42")
var ctx = context.Background()
// since `FIRST_FLAG_ENV_VAR` env is set, this will parse and use that value
var myFlag1 = flags.Uint64("first-flag-cmd-line", env.MustFromEnvOrDefault("FIRST_FLAG_ENV_VAR", 7), "an example uint64 flag") *uint64
var myFlag1 = flags.Uint64("first-flag-cmd-line", env.MustFromEnvOrDefault(ctx, "FIRST_FLAG_ENV_VAR", 7), "an example uint64 flag") *uint64
// since `SECOND_FLAG_ENV_VAR` env is not set, this will fallback to the default value
var myFlag2 = flags.Uint64("second-flag-cmd-line", env.MustFromEnvOrDefault("SECOND_FLAG_ENV_VAR", 7), "an example uint64 flag") *uint64
var myFlag2 = flags.Uint64("second-flag-cmd-line", env.MustFromEnvOrDefault(ctx, "SECOND_FLAG_ENV_VAR", 7), "an example uint64 flag") *uint64
fmt.Printf("%d; %d", *myFlag1, *myFlag2) // outputs -> 42; 7
```

Since `MustFromEnvOrDefault` (and its error returning counterpart `FromEnvOrDefault`) use generics and analyze the type dynamically, the call looks very similar for other flag data types.

```go
var myStrFlag = flags.String("my-str-flag", env.MustFromEnvOrDefault("MY_STR", "a string"), "an example string flag") *string
var myBoolFlag = flags.Bool("my-bool-flag", env.MustFromEnvOrDefault("MY_BOOL", true), "an example bool flag") *bool
var myDurFlag = flags.Duration("my-duration-flag", env.MustFromEnvOrDefault("MY_DURATION", time.Second * 5), "an example duration flag") *time.Duration
var myFloatFlag = flags.Float64("my-float64-flag", env.MustFromEnvOrDefault("MY_FLOAT64", 7.11), "an example float64 flag") *float64
var ctx = context.Background()
var myStrFlag = flags.String("my-str-flag", env.MustFromEnvOrDefault(ctx, "MY_STR", "a string"), "an example string flag") *string
var myBoolFlag = flags.Bool("my-bool-flag", env.MustFromEnvOrDefault(ctx, "MY_BOOL", true), "an example bool flag") *bool
var myDurFlag = flags.Duration("my-duration-flag", env.MustFromEnvOrDefault(ctx, "MY_DURATION", time.Second * 5), "an example duration flag") *time.Duration
var myFloatFlag = flags.Float64("my-float64-flag", env.MustFromEnvOrDefault(ctx, "MY_FLOAT64", 7.11), "an example float64 flag") *float64
```

### Without flags.
Expand All @@ -54,14 +58,15 @@ Of course, usage with [flag](https://pkg.go.dev/flag) is not strictly necessary

```go
var (
myStrVar = env.MustFromEnvOrDefault("MY_STR", "a string")
myBoolVar = env.MustFromEnvOrDefault("MY_BOOL", true)
ctx = context.Background()
myStrVar = env.MustFromEnvOrDefault(ctx, "MY_STR", "a string")
myBoolVar = env.MustFromEnvOrDefault(ctx, "MY_BOOL", true)
)

// --- or ---

myStrVar2, err := env.FromEnvOrDefault("MY_STR", "a string")
myStrVar2, err := env.FromEnvOrDefault(ctx, "MY_STR", "a string")
if err != nil { ... }
myBoolVar2, err := env.FromEnvOrDefault("MY_BOOL", true)
myBoolVar2, err := env.FromEnvOrDefault(ctx, "MY_BOOL", true)
if err != nil { ... }
```
```
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
module github.com/ndisidore/go-env

go 1.18
go 1.22
24 changes: 24 additions & 0 deletions options.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,16 @@ package env
import (
"errors"
"os"
"time"
)

type (
envParseOpts struct {
envLoader EnvLoader
separator string
defaultOnError bool
timeLayout string
sensitive bool
}

// EnvLoader is an alias for a function that loads values from the env. It mirrors the signature of os.Getenv.
Expand All @@ -24,6 +27,7 @@ var (
envLoader: os.Getenv,
separator: ",",
defaultOnError: false,
timeLayout: time.RFC3339,
}
)

Expand Down Expand Up @@ -60,3 +64,23 @@ func WithFallbackToDefaultOnError(fallback bool) EnvParseOption {
return nil
}
}

// WithTimeLayout allows overriding the time layout used to parse time.Time values. Default is RFC3339.
func WithTimeLayout(layout string) EnvParseOption {
return func(o *envParseOpts) error {
if layout == "" {
return errors.New("time layout cannot be empty string")
}

o.timeLayout = layout
return nil
}
}

// WithSensitive informs the parser that the value being parsed is sensitive and should not be logged.
func WithSensitive(sensitive bool) EnvParseOption {
return func(o *envParseOpts) error {
o.sensitive = sensitive
return nil
}
}
57 changes: 49 additions & 8 deletions parser.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
package env

import (
"context"
"fmt"
"log"
"log/slog"
"net/url"
"os"
"strconv"
"strings"
"time"
Expand All @@ -11,17 +14,18 @@ import (
type (
// Parseable represents the types the parser is capable of handling.
Parseable interface {
string | bool | int | uint | int64 | uint64 | float64 | time.Duration | []string | []bool | []int | []uint | []int64 | []uint64 | []float64
string | bool | int | uint | int64 | uint64 | float64 | time.Duration | time.Time | url.URL | []string | []bool | []int | []uint | []int64 | []uint64 | []float64 | []time.Duration | []time.Time | []url.URL
}
)

// MustFromEnvOrDefault attempts to parse the environment variable provided. If it is empty or missing, the default value is used.
//
// If an error is encountered, depending on whether the `WithFallbackToDefaultOnError` option is provided it will either fallback or fatally log & exit.
func MustFromEnvOrDefault[T Parseable](envVar string, defaultVal T, opts ...EnvParseOption) (dest T) {
parsed, err := FromEnvOrDefault(envVar, defaultVal, opts...)
func MustFromEnvOrDefault[T Parseable](ctx context.Context, envVar string, defaultVal T, opts ...EnvParseOption) (dest T) {
parsed, err := FromEnvOrDefault(ctx, envVar, defaultVal, opts...)
if err != nil {
log.Fatal(err)
slog.Default().ErrorContext(ctx, "failed to parse env var", slog.String("env_var", envVar), slog.String("error", err.Error()))
os.Exit(1)
}

return parsed
Expand All @@ -30,7 +34,7 @@ func MustFromEnvOrDefault[T Parseable](envVar string, defaultVal T, opts ...EnvP
// FromEnvOrDefault attempts to parse the environment variable provided. If it is empty or missing, the default value is used.
//
// If an error is encountered, depending on whether the `WithFallbackToDefaultOnError` option is provided it will either fallback or return the error back to the client.
func FromEnvOrDefault[T Parseable](envVar string, defaultVal T, opts ...EnvParseOption) (dest T, err error) {
func FromEnvOrDefault[T Parseable](ctx context.Context, envVar string, defaultVal T, opts ...EnvParseOption) (dest T, err error) {
parseOpts := &defaultParseOptions
for _, opt := range opts {
if err := opt(parseOpts); err != nil {
Expand Down Expand Up @@ -65,12 +69,16 @@ func FromEnvOrDefault[T Parseable](envVar string, defaultVal T, opts ...EnvParse
v, err = strconv.ParseFloat(envStr, 64)
case time.Duration:
v, err = time.ParseDuration(envStr)
case time.Time:
v, err = time.Parse(parseOpts.timeLayout, envStr)
case url.URL:
v, err = url.Parse(envStr)
case []string:
v = strings.Split(envStr, parseOpts.separator)
case []bool:
vs := make([]bool, 0)
for i, at := range splitAndTrim(envStr, parseOpts.separator) {
parsed, innerErr := strconv.ParseBool(strings.TrimSpace(at))
parsed, innerErr := strconv.ParseBool(at)
if innerErr != nil {
err = fmt.Errorf("item %s (pos: %d) failed to parse: %w", at, i, innerErr)
break
Expand Down Expand Up @@ -133,6 +141,39 @@ func FromEnvOrDefault[T Parseable](envVar string, defaultVal T, opts ...EnvParse
vs = append(vs, parsed)
}
v = vs
case []time.Duration:
vs := make([]time.Duration, 0)
for i, at := range splitAndTrim(envStr, parseOpts.separator) {
parsed, innerErr := time.ParseDuration(at)
if innerErr != nil {
err = fmt.Errorf("item %s (pos: %d) failed to parse: %w", at, i, innerErr)
break
}
vs = append(vs, parsed)
}
v = vs
case []time.Time:
vs := make([]time.Time, 0)
for i, at := range splitAndTrim(envStr, parseOpts.separator) {
parsed, innerErr := time.Parse(parseOpts.timeLayout, at)
if innerErr != nil {
err = fmt.Errorf("item %s (pos: %d) failed to parse: %w", at, i, innerErr)
break
}
vs = append(vs, parsed)
}
v = vs
case []url.URL:
vs := make([]url.URL, 0)
for i, at := range splitAndTrim(envStr, parseOpts.separator) {
parsed, innerErr := url.Parse(at)
if innerErr != nil {
err = fmt.Errorf("item %s (pos: %d) failed to parse: %w", at, i, innerErr)
break
}
vs = append(vs, *parsed)
}
v = vs
}
if err != nil {
if parseOpts.defaultOnError {
Expand All @@ -144,7 +185,7 @@ func FromEnvOrDefault[T Parseable](envVar string, defaultVal T, opts ...EnvParse

dest, ok := v.(T)
if !ok {
return dest, fmt.Errorf("failed to cast env %s (value: %v) to %T", envVar, v, dest)
return dest, fmt.Errorf("failed to cast env %s to %T", envVar, dest)
}
return dest, nil
}
Expand Down
Loading
Loading