feat: initial release — env decoder extracted from ai-fabric
Public API: - Unmarshal(dst, opts...) - UnmarshalPrefix(dst, prefix, opts...) - AsMap() / AsMapPrefix(prefix) - Options: WithTrim, WithWeaklyTyped, WithTagName, WithDecodeHook Merges three divergent in-repo copies (ai-fabric/pkg/env, markets-platform/TP-general-code/pkg/system, and the var/agents/issue-* snapshots), adds error wrapping, prefix filter, and pluggable hooks. 15 pure-Go unit tests, no synthetic mocks, lint clean. Extracted per inventar/docs/asr/ASR-0008-ai-fabric-audit.md.
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
---
|
||||
description: No synthetic OnlyOffice/Gitea mocks; prefer real integration tests
|
||||
globs:
|
||||
- "**/*_test.go"
|
||||
alwaysApply: false
|
||||
---
|
||||
# Testing policy — no synthetic vendor mockups
|
||||
|
||||
When authoring tests under `github.com/eslider/go-onlyoffice`, do **not**
|
||||
build `httptest.NewServer` fixtures that emulate OnlyOffice, Gitea, or any
|
||||
other third-party API. Simulated vendor responses drift from reality, give
|
||||
false green signals, and hide protocol changes.
|
||||
|
||||
## What to do instead
|
||||
|
||||
1. **Unit tests** — pure Go, no network. Use them for parsers, encoders,
|
||||
struct conversions, pure helpers. No `httptest` that fakes the vendor.
|
||||
2. **Integration tests** — `//go:build integration` tag in a `*_integration_test.go`
|
||||
file. Read credentials from env:
|
||||
- `ONLYOFFICE_URL` / `ONLYOFFICE_HOST`
|
||||
- `ONLYOFFICE_USER` / `ONLYOFFICE_NAME`
|
||||
- `ONLYOFFICE_PASS` / `ONLYOFFICE_PASSWORD`
|
||||
Call `t.Skip("ONLYOFFICE_URL not set")` when credentials are absent so the
|
||||
regular `go test ./...` stays green in CI.
|
||||
3. **Run integration**: `go test -tags=integration ./...`.
|
||||
4. **Every new endpoint** ships with an integration test in the same PR.
|
||||
|
||||
## Narrow exception
|
||||
|
||||
`httptest.NewServer` is OK when verifying the **caller's own** HTTP
|
||||
behaviour (e.g. a user's handler or middleware we are wrapping). It is **not**
|
||||
OK when the test server is pretending to be OnlyOffice or Gitea.
|
||||
|
||||
## Migrating existing tests
|
||||
|
||||
If you find a test that handles routes like `/api/2.0/...` and returns canned
|
||||
JSON, convert it to an integration test (or delete it if the behaviour is
|
||||
already covered by integration).
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
# Local environment
|
||||
.env
|
||||
|
||||
# Go workspace (for local multi-module dev; see inventar ASR-0008)
|
||||
go.work
|
||||
go.work.sum
|
||||
|
||||
# Build artifacts
|
||||
/bin/
|
||||
/dist/
|
||||
@@ -0,0 +1,23 @@
|
||||
run:
|
||||
timeout: 5m
|
||||
tests: true
|
||||
|
||||
linters:
|
||||
disable-all: true
|
||||
enable:
|
||||
- errcheck
|
||||
- gofmt
|
||||
- goimports
|
||||
- govet
|
||||
- ineffassign
|
||||
- revive
|
||||
- staticcheck
|
||||
- unused
|
||||
|
||||
linters-settings:
|
||||
revive:
|
||||
rules:
|
||||
- name: var-naming
|
||||
- name: exported
|
||||
- name: package-comments
|
||||
- name: indent-error-flow
|
||||
@@ -0,0 +1,50 @@
|
||||
# AGENTS.md — `go-env`
|
||||
|
||||
This module is part of the eSlider `go-*` library standard (inventar ASR-0008).
|
||||
|
||||
## Purpose
|
||||
|
||||
Decode `os.Environ()` into Go structs. Extracted from
|
||||
`produktor.io/ai-fabric/pkg/env` on 2026-04-24.
|
||||
|
||||
## Public API surface
|
||||
|
||||
- `env.Unmarshal(dst any, opts ...Option) error`
|
||||
- `env.UnmarshalPrefix(dst any, prefix string, opts ...Option) error`
|
||||
- `env.AsMap() map[string]any`
|
||||
- `env.AsMapPrefix(prefix string) map[string]any`
|
||||
- Options: `WithTrim`, `WithWeaklyTyped`, `WithTagName`, `WithDecodeHook`
|
||||
|
||||
Breaking changes require a new major version tag (SemVer). Internal helpers
|
||||
(`asMapFromEnviron`, `trimStringHook`, `insertPath`) are unexported and
|
||||
may change without notice.
|
||||
|
||||
## Testing policy
|
||||
|
||||
Follows the eSlider "no synthetic mocks" policy:
|
||||
|
||||
- **Unit tests** (`env_test.go`): pure inputs only. We pass synthetic
|
||||
`environ` slices to `asMapFromEnviron` — that is not a mock of anything
|
||||
external; it's the normal way to test a pure function. Use `t.Setenv`
|
||||
for `Unmarshal*` happy-path tests since Go's `os.Environ` lookup is
|
||||
well-defined local behaviour, not a vendor protocol.
|
||||
- **No `httptest`** — this library has no HTTP surface.
|
||||
- **No integration-test build tag** — everything is in-process.
|
||||
|
||||
## Checklist before release
|
||||
|
||||
```sh
|
||||
cd go-env
|
||||
go mod tidy
|
||||
go vet ./...
|
||||
go test -race -count=1 ./...
|
||||
golangci-lint run --timeout 5m # same preset as go-onlyoffice
|
||||
```
|
||||
|
||||
Bump `CHANGELOG.md`, tag `vX.Y.Z`, push.
|
||||
|
||||
## Related
|
||||
|
||||
- `inventar/docs/asr/ASR-0008.md` — Go library module conventions
|
||||
- `inventar/docs/asr/ASR-0008-ai-fabric-audit.md` — why this module exists
|
||||
- `go-onlyoffice/AGENTS.md` — reference for the eSlider library template
|
||||
@@ -0,0 +1,32 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to `go-env` are documented here.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.1.0] - 2026-04-24
|
||||
|
||||
Initial release. Extracted from `produktor.io/ai-fabric/pkg/env` per
|
||||
`inventar/docs/asr/ASR-0008-ai-fabric-audit.md`.
|
||||
|
||||
### Added
|
||||
|
||||
- `Unmarshal(dst, opts...)` — decode all process env vars into a Go struct.
|
||||
- `UnmarshalPrefix(dst, prefix, opts...)` — prefix-scoped variant that
|
||||
strips the prefix before building the path.
|
||||
- `AsMap()` / `AsMapPrefix(prefix)` — escape hatch returning the nested
|
||||
`map[string]any` built from `os.Environ()`.
|
||||
- Options: `WithTrim`, `WithWeaklyTyped`, `WithTagName`, `WithDecodeHook`.
|
||||
- TrimSpace on string values is enabled by default; disable with
|
||||
`WithTrim(false)`.
|
||||
- Error wrapping with `%w` on both decoder configuration and decode
|
||||
failures — matches `markets-platform/TP-general-code/pkg/system`
|
||||
behaviour, improves on the original ai-fabric version.
|
||||
|
||||
### Tests
|
||||
|
||||
- 15 pure-Go unit tests. No mocks. `asMapFromEnviron` is tested with
|
||||
synthetic `environ` slices (not a mock — a pure input). Follows the
|
||||
eSlider `go-*` no-synthetic-mocks policy.
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Andriy Oblivantsev
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,97 @@
|
||||
# go-env
|
||||
|
||||
[](https://pkg.go.dev/github.com/eslider/go-env)
|
||||
|
||||
Tiny, zero-ceremony library for decoding process environment variables into
|
||||
Go structs.
|
||||
|
||||
- Uses `_` as a path separator: `SERVICE_HTTP_PORT` → `Service.HTTP.Port`.
|
||||
- Weakly-typed by default (`"1"` → `int`, `"true"` → `bool`).
|
||||
- Optional prefix filter with automatic stripping.
|
||||
- Pluggable decode hooks via [`mitchellh/mapstructure`][mapstructure].
|
||||
|
||||
## Install
|
||||
|
||||
```sh
|
||||
go get github.com/eslider/go-env
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/eslider/go-env"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Service struct {
|
||||
HTTP struct {
|
||||
Port int
|
||||
}
|
||||
Key string
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
var cfg Config
|
||||
if err := env.Unmarshal(&cfg); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
fmt.Printf("%+v\n", cfg)
|
||||
}
|
||||
```
|
||||
|
||||
With a prefix:
|
||||
|
||||
```go
|
||||
// Only looks at APP_* variables; strips the APP_ prefix before decoding.
|
||||
_ = env.UnmarshalPrefix(&cfg, "APP_")
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
| Function | Purpose |
|
||||
|---|---|
|
||||
| `Unmarshal(dst, opts...)` | Decode all env vars into `dst`. |
|
||||
| `UnmarshalPrefix(dst, prefix, opts...)` | Same, but only vars starting with `prefix`. |
|
||||
| `AsMap()` / `AsMapPrefix(prefix)` | Return the nested `map[string]any` used by the decoder (debugging, custom decoders). |
|
||||
|
||||
### Options
|
||||
|
||||
| Option | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `WithTrim(bool)` | `true` | TrimSpace every string value. |
|
||||
| `WithWeaklyTyped(bool)` | `true` | `mapstructure`'s weakly-typed coercion. |
|
||||
| `WithTagName(string)` | `"mapstructure"` | Struct tag name for field overrides. |
|
||||
| `WithDecodeHook(h)` | — | Append a `mapstructure.DecodeHookFunc` to the chain. |
|
||||
|
||||
## Semantics
|
||||
|
||||
- **Path collisions**: first write wins. If both `FOO=1` and `FOO=2` exist
|
||||
in the environ, only `FOO=1` is kept. This matches the original
|
||||
`ai-fabric/pkg/env` behaviour.
|
||||
- **Case-insensitive keys**: all path components are lower-cased; struct
|
||||
fields are matched via `mapstructure` which is also case-insensitive.
|
||||
- **`_`-only delimiter**: there's no escape — if a variable legitimately
|
||||
contains `_` inside a "leaf" name, you must restructure your struct to
|
||||
match the nested layout.
|
||||
|
||||
## Status
|
||||
|
||||
Extracted from `produktor.io/ai-fabric` as part of the eSlider `go-*`
|
||||
library standard (ASR-0008). Merges the best parts of three previously
|
||||
divergent copies:
|
||||
|
||||
- `produktor.io/ai-fabric/pkg/env`
|
||||
- `markets-platform/TP-general-code/pkg/system/env.go`
|
||||
- the various `pkg/system/env.go` snapshots inside `var/agents/issue-*/`
|
||||
|
||||
## License
|
||||
|
||||
MIT © Andriy Oblivantsev
|
||||
|
||||
[mapstructure]: https://github.com/mitchellh/mapstructure
|
||||
@@ -0,0 +1,28 @@
|
||||
// Package env reads process environment variables and decodes them into
|
||||
// Go structs using underscore-delimited paths.
|
||||
//
|
||||
// A variable named "SERVICE_HTTP_PORT" becomes the path Service.HTTP.Port
|
||||
// (case-insensitive, '_' is a path separator). Values are decoded via
|
||||
// github.com/mitchellh/mapstructure, so numeric, boolean and slice
|
||||
// conversions happen automatically.
|
||||
//
|
||||
// var cfg struct {
|
||||
// Service struct {
|
||||
// HTTP struct {
|
||||
// Port int
|
||||
// }
|
||||
// Key string
|
||||
// }
|
||||
// }
|
||||
// if err := env.Unmarshal(&cfg); err != nil { ... }
|
||||
//
|
||||
// UnmarshalPrefix ignores variables that don't start with the given
|
||||
// prefix and strips it before building the path:
|
||||
//
|
||||
// _ = os.Setenv("APP_DB_HOST", "localhost")
|
||||
// _ = env.UnmarshalPrefix(&cfg, "APP_")
|
||||
// // cfg.Db.Host == "localhost"
|
||||
//
|
||||
// String values are TrimSpace-trimmed by default; pass WithTrim(false) to
|
||||
// opt out.
|
||||
package env
|
||||
@@ -0,0 +1,139 @@
|
||||
package env
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"github.com/mitchellh/mapstructure"
|
||||
)
|
||||
|
||||
// Unmarshal decodes all process environment variables into dst. The variable
|
||||
// name is split by '_' and the resulting path is matched against dst's fields
|
||||
// case-insensitively.
|
||||
//
|
||||
// dst must be a pointer to a struct (or a map that mapstructure can populate).
|
||||
// See the package documentation for details and examples.
|
||||
func Unmarshal(dst any, opts ...Option) error {
|
||||
return UnmarshalPrefix(dst, "", opts...)
|
||||
}
|
||||
|
||||
// UnmarshalPrefix is like Unmarshal but only considers environment variables
|
||||
// that start with prefix. The prefix is stripped from each variable name
|
||||
// before the path is built.
|
||||
//
|
||||
// An empty prefix is equivalent to Unmarshal.
|
||||
func UnmarshalPrefix(dst any, prefix string, opts ...Option) error {
|
||||
o := defaultOptions()
|
||||
for _, f := range opts {
|
||||
f(&o)
|
||||
}
|
||||
|
||||
data := asMapFromEnviron(os.Environ(), prefix)
|
||||
|
||||
hooks := []mapstructure.DecodeHookFunc{}
|
||||
if o.trim {
|
||||
hooks = append(hooks, trimStringHook)
|
||||
}
|
||||
hooks = append(hooks, o.extraHooks...)
|
||||
|
||||
decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{
|
||||
Result: dst,
|
||||
WeaklyTypedInput: o.weaklyTyped,
|
||||
TagName: o.tagName,
|
||||
DecodeHook: mapstructure.ComposeDecodeHookFunc(hooks...),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("env: configure decoder: %w", err)
|
||||
}
|
||||
if err := decoder.Decode(data); err != nil {
|
||||
return fmt.Errorf("env: decode: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AsMap returns a nested map built from all current process environment
|
||||
// variables, using '_' as path separator. Keys are lower-cased.
|
||||
//
|
||||
// It's primarily useful for debugging or for callers that want to plug
|
||||
// their own decoder; normal code should prefer Unmarshal.
|
||||
func AsMap() map[string]any {
|
||||
return asMapFromEnviron(os.Environ(), "")
|
||||
}
|
||||
|
||||
// AsMapPrefix is the prefix-scoped variant of AsMap. Variables whose names
|
||||
// don't start with prefix are skipped; the prefix itself is stripped before
|
||||
// the map is built.
|
||||
func AsMapPrefix(prefix string) map[string]any {
|
||||
return asMapFromEnviron(os.Environ(), prefix)
|
||||
}
|
||||
|
||||
// asMapFromEnviron is the core building block. Exposed as an unexported
|
||||
// function so tests can feed it a deterministic environ slice instead of
|
||||
// mutating the real process env.
|
||||
func asMapFromEnviron(environ []string, prefix string) map[string]any {
|
||||
out := make(map[string]any)
|
||||
|
||||
for _, entry := range environ {
|
||||
eq := strings.IndexByte(entry, '=')
|
||||
if eq < 0 {
|
||||
// Malformed entry — shouldn't happen on Unix but guard anyway.
|
||||
continue
|
||||
}
|
||||
name, value := entry[:eq], entry[eq+1:]
|
||||
|
||||
if prefix != "" {
|
||||
if !strings.HasPrefix(name, prefix) {
|
||||
continue
|
||||
}
|
||||
name = name[len(prefix):]
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
insertPath(out, strings.Split(name, "_"), value)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// insertPath walks path inside root, creating intermediate nested maps as
|
||||
// needed, and stores value at the leaf.
|
||||
//
|
||||
// Collisions are resolved by preferring the first write: once a leaf value
|
||||
// is set for a path, a later sibling with the same path prefix will NOT
|
||||
// overwrite it. This matches the behaviour of the ai-fabric original so
|
||||
// that tools migrating to go-env don't observe new surprises.
|
||||
func insertPath(root map[string]any, path []string, value string) {
|
||||
current := root
|
||||
last := len(path) - 1
|
||||
for i, raw := range path {
|
||||
k := strings.ToLower(raw)
|
||||
|
||||
if i == last {
|
||||
if _, exists := current[k]; exists {
|
||||
return
|
||||
}
|
||||
current[k] = value
|
||||
return
|
||||
}
|
||||
|
||||
next, ok := current[k].(map[string]any)
|
||||
if !ok {
|
||||
next = make(map[string]any)
|
||||
current[k] = next
|
||||
}
|
||||
current = next
|
||||
}
|
||||
}
|
||||
|
||||
func trimStringHook(from, to reflect.Type, data any) (any, error) {
|
||||
if from.Kind() == reflect.String && to.Kind() == reflect.String {
|
||||
if s, ok := data.(string); ok {
|
||||
return strings.TrimSpace(s), nil
|
||||
}
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
+222
@@ -0,0 +1,222 @@
|
||||
package env
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// asMapFromEnviron is tested with a synthetic environ slice — this is NOT a
|
||||
// mock of anything external. It's a pure-input unit test that isolates the
|
||||
// core mapping logic from process-global state.
|
||||
|
||||
func TestAsMapFromEnviron_FlatAndNested(t *testing.T) {
|
||||
got := asMapFromEnviron([]string{
|
||||
"FOO=bar",
|
||||
"SERVICE_HTTP_PORT=8080",
|
||||
"SERVICE_KEY=abc",
|
||||
}, "")
|
||||
|
||||
want := map[string]any{
|
||||
"foo": "bar",
|
||||
"service": map[string]any{
|
||||
"http": map[string]any{"port": "8080"},
|
||||
"key": "abc",
|
||||
},
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAsMapFromEnviron_PreservesValuesWithEquals(t *testing.T) {
|
||||
got := asMapFromEnviron([]string{"TOKEN=a=b=c="}, "")
|
||||
if got["token"] != "a=b=c=" {
|
||||
t.Fatalf("expected full value preserved, got %#v", got["token"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAsMapFromEnviron_PrefixFilterAndStrip(t *testing.T) {
|
||||
got := asMapFromEnviron([]string{
|
||||
"APP_DB_HOST=localhost",
|
||||
"APP_DB_PORT=5432",
|
||||
"OTHER_THING=skip",
|
||||
}, "APP_")
|
||||
|
||||
want := map[string]any{
|
||||
"db": map[string]any{"host": "localhost", "port": "5432"},
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAsMapFromEnviron_EmptyAndMalformedAreSkipped(t *testing.T) {
|
||||
got := asMapFromEnviron([]string{"NOEQUALS", ""}, "")
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("expected no entries, got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAsMapFromEnviron_FirstWriteWinsOnCollision(t *testing.T) {
|
||||
// This matches the ai-fabric original: once a leaf is set for a path,
|
||||
// subsequent entries that would overwrite it are ignored.
|
||||
got := asMapFromEnviron([]string{
|
||||
"A=1",
|
||||
"A=2",
|
||||
}, "")
|
||||
if got["a"] != "1" {
|
||||
t.Fatalf("expected first-write-wins (a=1), got %#v", got["a"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAsMapFromEnviron_PrefixOnlyVariableIsSkipped(t *testing.T) {
|
||||
// "APP_=xxx" with prefix "APP_" would strip to empty key — we skip it.
|
||||
got := asMapFromEnviron([]string{"APP_=oops"}, "APP_")
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("expected empty map, got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Decoder / option tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestUnmarshal_BasicStruct(t *testing.T) {
|
||||
t.Setenv("SERVICE_KEY", "abc")
|
||||
t.Setenv("SERVICE_PORT", "8080")
|
||||
|
||||
var cfg struct {
|
||||
Service struct {
|
||||
Key string
|
||||
Port int
|
||||
}
|
||||
}
|
||||
if err := Unmarshal(&cfg); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if cfg.Service.Key != "abc" {
|
||||
t.Errorf("Key = %q, want abc", cfg.Service.Key)
|
||||
}
|
||||
if cfg.Service.Port != 8080 {
|
||||
t.Errorf("Port = %d, want 8080 (weakly-typed string->int)", cfg.Service.Port)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnmarshal_TrimsStringsByDefault(t *testing.T) {
|
||||
t.Setenv("NAME", " hello ")
|
||||
|
||||
var cfg struct{ Name string }
|
||||
if err := Unmarshal(&cfg); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if cfg.Name != "hello" {
|
||||
t.Fatalf("expected trimmed value, got %q", cfg.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnmarshal_WithTrimOff(t *testing.T) {
|
||||
t.Setenv("NAME", " keep me ")
|
||||
|
||||
var cfg struct{ Name string }
|
||||
if err := Unmarshal(&cfg, WithTrim(false)); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if cfg.Name != " keep me " {
|
||||
t.Fatalf("expected untrimmed value, got %q", cfg.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnmarshalPrefix_StripsAndFilters(t *testing.T) {
|
||||
t.Setenv("APP_DB_HOST", "localhost")
|
||||
t.Setenv("APP_DB_PORT", "5432")
|
||||
t.Setenv("OTHER_DB_HOST", "should-be-ignored")
|
||||
|
||||
var cfg struct {
|
||||
DB struct {
|
||||
Host string
|
||||
Port int
|
||||
} `mapstructure:"db"`
|
||||
}
|
||||
if err := UnmarshalPrefix(&cfg, "APP_"); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if cfg.DB.Host != "localhost" {
|
||||
t.Errorf("Host = %q, want localhost", cfg.DB.Host)
|
||||
}
|
||||
if cfg.DB.Port != 5432 {
|
||||
t.Errorf("Port = %d, want 5432", cfg.DB.Port)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnmarshal_WithTagName(t *testing.T) {
|
||||
t.Setenv("DATABASE_URL", "postgres://x")
|
||||
|
||||
// The map is always nested by '_', so tags also reference the nested
|
||||
// path, one component per struct level. Here we override the outer
|
||||
// field name via a custom tag.
|
||||
var cfg struct {
|
||||
DB struct {
|
||||
URL string
|
||||
} `env:"database"`
|
||||
}
|
||||
if err := Unmarshal(&cfg, WithTagName("env")); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if cfg.DB.URL != "postgres://x" {
|
||||
t.Fatalf("URL = %q, want postgres://x", cfg.DB.URL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnmarshal_WeaklyTypedOff(t *testing.T) {
|
||||
t.Setenv("PORT", "8080")
|
||||
|
||||
var cfg struct{ Port int }
|
||||
err := Unmarshal(&cfg, WithWeaklyTyped(false))
|
||||
// With strict typing, "8080" (string) cannot decode into an int.
|
||||
if err == nil {
|
||||
t.Fatal("expected strict typing to reject string->int coercion")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnmarshal_SpecialCharactersSurvive(t *testing.T) {
|
||||
// Regression: keys with special chars in their values must pass through
|
||||
// unchanged (modulo TrimSpace).
|
||||
t.Setenv("SPECIAL", "@!#$%^&*()")
|
||||
|
||||
var cfg struct{ Special string }
|
||||
if err := Unmarshal(&cfg); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if cfg.Special != "@!#$%^&*()" {
|
||||
t.Fatalf("Special = %q, want @!#$%%^&*()", cfg.Special)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnmarshal_RejectsNonPointer(t *testing.T) {
|
||||
var cfg struct{ A string }
|
||||
err := Unmarshal(cfg) // no pointer
|
||||
if err == nil {
|
||||
t.Fatal("expected error when passing a non-pointer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAsMap_ExposesCurrentEnv(t *testing.T) {
|
||||
t.Setenv("GO_ENV_ASMAP_PROBE", "hit")
|
||||
|
||||
m := AsMap()
|
||||
goenv, ok := m["go"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected nested map under 'go', got %T", m["go"])
|
||||
}
|
||||
env, ok := goenv["env"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected nested map under 'go.env', got %T", goenv["env"])
|
||||
}
|
||||
asmap, ok := env["asmap"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected nested map under 'go.env.asmap', got %T", env["asmap"])
|
||||
}
|
||||
if asmap["probe"] != "hit" {
|
||||
t.Fatalf("probe = %v, want hit", asmap["probe"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
module github.com/eslider/go-env
|
||||
|
||||
go 1.22.2
|
||||
|
||||
require github.com/mitchellh/mapstructure v1.5.0
|
||||
@@ -0,0 +1,2 @@
|
||||
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
package env
|
||||
|
||||
import "github.com/mitchellh/mapstructure"
|
||||
|
||||
// Option customises Unmarshal/UnmarshalPrefix behaviour.
|
||||
type Option func(*options)
|
||||
|
||||
type options struct {
|
||||
trim bool
|
||||
weaklyTyped bool
|
||||
tagName string
|
||||
extraHooks []mapstructure.DecodeHookFunc
|
||||
}
|
||||
|
||||
func defaultOptions() options {
|
||||
return options{
|
||||
trim: true,
|
||||
weaklyTyped: true,
|
||||
tagName: "mapstructure",
|
||||
}
|
||||
}
|
||||
|
||||
// WithTrim toggles automatic TrimSpace on string values. Default: true.
|
||||
// Use WithTrim(false) when whitespace is semantically meaningful.
|
||||
func WithTrim(enable bool) Option {
|
||||
return func(o *options) { o.trim = enable }
|
||||
}
|
||||
|
||||
// WithWeaklyTyped toggles mapstructure's WeaklyTypedInput. Default: true.
|
||||
// When true, "1" decodes into int, "true" into bool, etc. Disable for
|
||||
// strict string-only decoding.
|
||||
func WithWeaklyTyped(enable bool) Option {
|
||||
return func(o *options) { o.weaklyTyped = enable }
|
||||
}
|
||||
|
||||
// WithTagName selects the struct tag mapstructure uses for field names.
|
||||
// Default: "mapstructure". Set "env" to use `env:"FIELD_NAME"` tags.
|
||||
func WithTagName(name string) Option {
|
||||
return func(o *options) { o.tagName = name }
|
||||
}
|
||||
|
||||
// WithDecodeHook appends a user hook to the decoder chain. Hooks run after
|
||||
// the built-in trim hook (unless trimming is disabled) and in the order
|
||||
// they're added.
|
||||
func WithDecodeHook(h mapstructure.DecodeHookFunc) Option {
|
||||
return func(o *options) { o.extraHooks = append(o.extraHooks, h) }
|
||||
}
|
||||
Reference in New Issue
Block a user