diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index a2bc30a..c17a918 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -14,15 +14,15 @@ jobs: test: strategy: matrix: - go-version: [1.22.x,1.21.x,1.20.x,1.19.x,1.18.x,1.17.x] + go-version: [1.25.x,1.24.x,1.23.x,1.22.x,1.21.x,1.20.x,1.19.x,1.18.x,1.17.x] os: [ubuntu-latest, macos-latest, windows-latest] runs-on: ${{ matrix.os }} steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Install Go - uses: actions/setup-go@v5 + uses: actions/setup-go@v6 with: go-version: ${{ matrix.go-version }} @@ -33,8 +33,8 @@ jobs: name: lint runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 with: go-version: stable - name: golangci-lint diff --git a/CHANGELOG.md b/CHANGELOG.md index c7553da..92e1048 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [5.31.0] - 2025-12-20 +### Added +- Add new `osext` package with some helpers for fetching and parsing ENV variables. + ## [5.30.0] - 2024-06-01 ### Changed - Changed NanoTome to not use linkname due to Go1.23 upcoming breaking changes. @@ -136,7 +140,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Added `timext.NanoTime` for fast low level monotonic time with nanosecond precision. -[Unreleased]: https://github.com/go-playground/pkg/compare/v5.30.0...HEAD +[Unreleased]: https://github.com/go-playground/pkg/compare/v5.31.0...HEAD +[5.31.0]: https://github.com/go-playground/pkg/compare/v5.30.0..v5.31.0 [5.30.0]: https://github.com/go-playground/pkg/compare/v5.29.1..v5.30.0 [5.29.1]: https://github.com/go-playground/pkg/compare/v5.29.0..v5.29.1 [5.29.0]: https://github.com/go-playground/pkg/compare/v5.28.1..v5.29.0 diff --git a/README.md b/README.md index 02b8072..316018d 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # pkg -![Project status](https://img.shields.io/badge/version-5.30.0-green.svg) +[![GitHub release (latest SemVer)](https://img.shields.io/github/v/release/go-playground/pkg)](https://github.com/go-playground/pkg/releases) [![Lint & Test](https://github.com/go-playground/pkg/actions/workflows/go.yml/badge.svg)](https://github.com/go-playground/pkg/actions/workflows/go.yml) [![Coverage Status](https://coveralls.io/repos/github/go-playground/pkg/badge.svg?branch=master)](https://coveralls.io/github/go-playground/pkg?branch=master) [![GoDoc](https://godoc.org/github.com/go-playground/pkg?status.svg)](https://pkg.go.dev/mod/github.com/go-playground/pkg/v5) diff --git a/os/env.go b/os/env.go new file mode 100644 index 0000000..9e07fae --- /dev/null +++ b/os/env.go @@ -0,0 +1,79 @@ +//go:build go1.22 +// +build go1.22 + +package osext + +import ( + "os" + "reflect" + "strconv" + + constraintsext "github.com/go-playground/pkg/v5/constraints" + . "github.com/go-playground/pkg/v5/values/option" + . "github.com/go-playground/pkg/v5/values/result" +) + +// EnvDefaults interface defines the supported types that can be used for environment variables conversions. +type EnvDefaults interface { + constraintsext.Number | ~string +} + +// Env retrieves the value of the environment variable named by the key. +// +// If the variable is not set, or if the conversion fails due to incorrect value, +// a default value is returned. +func Env[T EnvDefaults](key string, defaultValue T) T { + return GetEnv[T](key).UnwrapOr(defaultValue) +} + +// GetEnv retrieves the value of the environment variable named by the key. +// +// If the variable is not set, or if the conversion fails it returns `None`, otherwise `Some`. +func GetEnv[T EnvDefaults](key string) Option[T] { + r := LookupEnv[T](key) + if r.IsErr() { + return None[T]() + } + return r.Unwrap() +} + +// LookupEnv retrieves the value of the environment variable named by the key. +// +// If the variable is not present it returns Ok(None) +// If the variable is present and conversion is successful, the value Ok(Some) is returned +// If the variable is present and conversion fails it returns Err(error) +func LookupEnv[T EnvDefaults](key string) Result[Option[T], error] { + if v, ok := os.LookupEnv(key); ok { + ty := reflect.TypeFor[T]() + elem := reflect.New(ty).Elem() + + switch ty.Kind() { + case reflect.String: + elem.SetString(v) + return Ok[Option[T], error](Some(elem.Interface().(T))) + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + i, err := strconv.ParseInt(v, 10, ty.Bits()) + if err != nil { + return Err[Option[T], error](err) + } + elem.SetInt(i) + return Ok[Option[T], error](Some(elem.Interface().(T))) + + case reflect.Uintptr, reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + i, err := strconv.ParseUint(v, 10, ty.Bits()) + if err != nil { + return Err[Option[T], error](err) + } + elem.SetUint(i) + return Ok[Option[T], error](Some(elem.Interface().(T))) + case reflect.Float32, reflect.Float64: + f, err := strconv.ParseFloat(v, ty.Bits()) + if err != nil { + return Err[Option[T], error](err) + } + elem.SetFloat(f) + return Ok[Option[T], error](Some(elem.Interface().(T))) + } + } + return Ok[Option[T], error](None[T]()) +} diff --git a/os/env_test.go b/os/env_test.go new file mode 100644 index 0000000..07a6685 --- /dev/null +++ b/os/env_test.go @@ -0,0 +1,98 @@ +//go:build go1.22 +// +build go1.22 + +package osext + +import ( + "fmt" + "math/rand" + "testing" +) + +type ( + CustomInt int + CustomInt8 int8 + CustomInt16 int16 + CustomInt32 int32 + CustomInt64 int64 + CustomUint uint + CustomUint8 uint8 + CustomUint16 uint16 + CustomUint32 uint32 + CustomUint64 uint64 + CustomUintptr uintptr + CustomFloat32 float32 + CustomFloat64 float64 + CustomString string +) + +func TestEnv(t *testing.T) { + tests := []struct { + name string + testFunc func() error + }{ + // Integer types + {"int", func() error { return SetAndUnsetTest(t, "24", 24, 42) }}, + {"CustomInt", func() error { return SetAndUnsetTest(t, "24", CustomInt(24), CustomInt(42)) }}, + {"int8", func() error { return SetAndUnsetTest(t, "24", int8(24), int8(42)) }}, + {"CustomInt8", func() error { return SetAndUnsetTest(t, "24", CustomInt8(24), CustomInt8(42)) }}, + {"int16", func() error { return SetAndUnsetTest(t, "24", int16(24), int16(42)) }}, + {"CustomInt16", func() error { return SetAndUnsetTest(t, "24", CustomInt16(24), CustomInt16(42)) }}, + {"int32", func() error { return SetAndUnsetTest(t, "24", int32(24), int32(42)) }}, + {"CustomInt32", func() error { return SetAndUnsetTest(t, "24", CustomInt32(24), CustomInt32(42)) }}, + {"int64", func() error { return SetAndUnsetTest(t, "24", int64(24), int64(42)) }}, + {"CustomInt64", func() error { return SetAndUnsetTest(t, "24", CustomInt64(24), CustomInt64(42)) }}, + {"uint", func() error { return SetAndUnsetTest(t, "24", uint(24), uint(42)) }}, + {"CustomUint", func() error { return SetAndUnsetTest(t, "24", CustomUint(24), CustomUint(42)) }}, + {"uint8", func() error { return SetAndUnsetTest(t, "24", uint8(24), uint8(42)) }}, + {"CustomUint8", func() error { return SetAndUnsetTest(t, "24", CustomUint8(24), CustomUint8(42)) }}, + {"uint16", func() error { return SetAndUnsetTest(t, "24", uint16(24), uint16(42)) }}, + {"CustomUint16", func() error { return SetAndUnsetTest(t, "24", CustomUint16(24), CustomUint16(42)) }}, + {"uint32", func() error { return SetAndUnsetTest(t, "24", uint32(24), uint32(42)) }}, + {"CustomUint32", func() error { return SetAndUnsetTest(t, "24", CustomUint32(24), CustomUint32(42)) }}, + {"uint64", func() error { return SetAndUnsetTest(t, "24", uint64(24), uint64(42)) }}, + {"CustomUint64", func() error { return SetAndUnsetTest(t, "24", CustomUint64(24), CustomUint64(42)) }}, + {"uintptr", func() error { return SetAndUnsetTest(t, "24", uintptr(24), uintptr(42)) }}, + {"CustomUintptr", func() error { return SetAndUnsetTest(t, "24", CustomUintptr(24), CustomUintptr(42)) }}, + + // Float types + {"float32", func() error { return SetAndUnsetTest(t, "24.5", float32(24.5), float32(42.5)) }}, + {"CustomFloat32", func() error { return SetAndUnsetTest(t, "24.5", CustomFloat32(24.5), CustomFloat32(42.5)) }}, + {"float64", func() error { return SetAndUnsetTest(t, "24.5", float64(24.5), float64(42.5)) }}, + {"CustomFloat64", func() error { return SetAndUnsetTest(t, "24.5", CustomFloat64(24.5), CustomFloat64(42.5)) }}, + + // String types + {"string", func() error { return SetAndUnsetTest(t, "hello", "hello", "world") }}, + {"CustomString", func() error { return SetAndUnsetTest(t, "hello", CustomString("hello"), CustomString("world")) }}, + + // Conversion failure test cases - these should return default values when conversion fails + {"int_conversion_failure", func() error { return SetAndUnsetTest(t, "not_a_number", 42, 42) }}, + {"float32_conversion_failure", func() error { return SetAndUnsetTest(t, "not_a_float", float32(42.5), float32(42.5)) }}, + {"float64_conversion_failure", func() error { return SetAndUnsetTest(t, "not_a_float", float64(42.5), float64(42.5)) }}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := tt.testFunc(); err != nil { + t.Errorf("Test %s failed: %v", tt.name, err) + } + }) + } +} + +func SetAndUnsetTest[T EnvDefaults](t *testing.T, envValueSet string, expectedSet T, expectedDefault T) error { + constTestEnvKey := fmt.Sprintf("TEST_ENV_%d", rand.Intn(1000000)) + + v := Env(constTestEnvKey, expectedDefault) + if v != expectedDefault { + return fmt.Errorf("default value mismatch: got %v, want %v", v, expectedDefault) + } + + t.Setenv(constTestEnvKey, envValueSet) + + v = Env(constTestEnvKey, expectedDefault) + if v != expectedSet { + return fmt.Errorf("set value mismatch: got %v, want %v", v, expectedSet) + } + return nil +}