go-toolkit

module
v0.5.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 10, 2026 License: MIT

README

go-toolkit

A collection of lightweight utility packages for Go. No external dependencies — standard library only.

Packages

Package Description
env Environment variable loader with .env file support and typed getters. ErrNotSet separates an absent variable from one set to an unparseable value
logger Structured logger built on log/slog with file and context support
secrets Pluggable secret source with TTL caching and ordered fallback. Env and File sources

Requirements

  • Go 1.21+

Installation

go get codeberg.org/obadness/go-toolkit

env

import "codeberg.org/obadness/go-toolkit/env"
Loading a .env file
if err := env.Load(".env"); err != nil {
    log.Fatal(err)
}

// Or, when the file is optional — typically outside development:
if err := env.LoadIfExists(".env"); err != nil {
    log.Fatal(err)
}

Variables are loaded into the process environment via os.Setenv and are immediately available via os.Getenv or any of the helpers below. Variables already set in the environment are not overwritten — including those deliberately set to an empty value.

.env file format
# Comments are ignored
APP_NAME=myapp
PORT=8080              # trailing comments are stripped
DEBUG=true
ALLOWED_HOSTS=localhost,127.0.0.1

# Quoted values are supported
DB_URL="postgres://user:pass@localhost:5432/db"
SECRET='my secret value'

# A shell-sourceable file works too
export API_KEY=abc123

# A # with no whitespace before it belongs to the value
PASSWORD=p@ss#word

Supported syntax:

  • KEY=value
  • KEY="value" or KEY='value' — matching quotes are stripped, and whitespace inside them is preserved
  • export KEY=value — the export prefix is ignored
  • Lines starting with # are comments
  • Outside quotes, a # preceded by whitespace starts a trailing comment; a # with no whitespace before it is part of the value
  • Blank lines are ignored
  • Values may contain = — only the first = splits the line
  • Lines up to 1 MB are supported, so a single-line PEM key or certificate loads fine

A malformed line — no =, an empty key, an unterminated quote, or stray text after a closing quote — is an error naming the file, line number and key.

Quotes do not nest, so a value containing an apostrophe must use double quotes:

NAME="it's fine"    # correct
NAME='it's fine'    # error: the quote closes after "it"
Helpers
Parse

Returns the value or an error if the variable is not set. A variable set to an empty value returns that empty value without an error. All typed getters are built on it.

dsn, err := env.Parse("DATABASE_URL")
if err != nil {
    log.Fatal(err)
}
ParseInt
port, err := env.ParseInt("PORT")
ParseInt32
maxConn, err := env.ParseInt32("MAX_CONNECTIONS")
ParseInt64
maxSize, err := env.ParseInt64("MAX_FILE_SIZE")
ParseUint
workers, err := env.ParseUint("WORKERS")
ParseUint32
maxConn, err := env.ParseUint32("MAX_CONNECTIONS")
ParseUint64
maxSize, err := env.ParseUint64("MAX_FILE_SIZE")
ParseFloat64
rate, err := env.ParseFloat64("RATE_LIMIT")
ParseBool

Accepts 1, t, true, TRUE, 0, f, false, FALSE.

debug, err := env.ParseBool("DEBUG")
ParseDuration

Parses a Go duration string (e.g. 500ms, 30s, 1h).

timeout, err := env.ParseDuration("REQUEST_TIMEOUT")
ParseList

Parses a comma-separated variable into a string slice. Whitespace is trimmed.

// ALLOWED_HOSTS=localhost, 127.0.0.1, ::1
hosts, err := env.ParseList("ALLOWED_HOSTS")
// ["localhost", "127.0.0.1", "::1"]
Or — fall back to a default

Returns the fallback when the variable is unset. Works with any of the getters above.

port    := env.Or(env.ParseInt,      "PORT",    8080)
timeout := env.Or(env.ParseDuration, "TIMEOUT", 30*time.Second)
host    := env.Or(env.Parse,         "HOST",    "0.0.0.0")

A variable that is set but cannot be parsed is a configuration error, not a missing value, and Or panics: PORT=8O80 with a letter O would otherwise be indistinguishable from PORT being absent. To handle a bad value instead of crashing, call the getter directly — each returns an error, and an absent variable is reported as env.ErrNotSet:

port, err := env.ParseInt("PORT")
switch {
case errors.Is(err, env.ErrNotSet):
    port = 8080
case err != nil:
    return fmt.Errorf("PORT: %w", err)
}
Must — fail fast

Panics when the variable is missing or invalid. For start-up configuration the program cannot run without.

dsn  := env.Must(env.Parse("DATABASE_URL"))
port := env.Must(env.ParseInt("PORT"))
Environment helpers
env.IsDev()   // true when APP_ENV or ENV is "development" or "dev"
env.IsProd()  // true when APP_ENV or ENV is "production" or "prod"
env.IsTest()  // true when APP_ENV or ENV is "test"

APP_ENV takes precedence over ENV, and the comparison is case-insensitive.

Notes
  • Load does not overwrite variables already set in the environment.
  • Load calls os.Setenv, which sets variables only for the current process.
  • In production (Docker, systemd, Kubernetes) you typically do not need Load — inject variables directly via the runtime and use only the helper functions.
Typical setup
func main() {
    if err := env.LoadIfExists(".env"); err != nil {
        log.Fatal(err)
    }

    var (
        dsn  = env.Must(env.Parse("DATABASE_URL"))
        port = env.Or(env.ParseInt, "PORT", 8080)
    )
}

logger

Structured logger built on Go's standard log/slog. Supports text and JSON output, daily rotating log files, per-level file routing, and context propagation.

import "codeberg.org/obadness/go-toolkit/logger"

Logger embeds *slog.Logger, so the entire slog API is available on it.

Create a logger
// Text format — good for development, stderr only
log := logger.NewDefault()

// JSON format — good for production log aggregators, stderr only
log := logger.NewJSON()

// JSON + daily rotating per-level files + stderr
// Produces: logs/app_debug_20260117.log, logs/app_info_20260117.log etc.
log := logger.NewWithFile("./logs")

// JSON + single daily rotating file + stderr
// Produces: logs/myapp_20260117.log
log := logger.NewWithCombinedFile("./logs", "myapp")

// Full control
log := logger.New(logger.Options{
    Level:     logger.Debug,
    JSON:      true,
    AddSource: true,
    File: &logger.FileOptions{
        Dir:      "./logs",
        Prefix:   "myapp",
        PerLevel: true,
    },
})
Basic usage
log.Info("server started", slog.String("address", "localhost:8080"))
log.Debug("request received", slog.String("method", "GET"), slog.String("path", "/users"))
log.Warn("slow query", slog.Duration("duration", 2*time.Second))
log.Error("database error", slog.Any("error", err))
File output

When file logging is enabled every log entry is written to both the console and the log file. Files rotate daily — a new file is created automatically when the date changes.

Per-level files (PerLevel: true):

logs/
├── myapp_debug_20260117.log
├── myapp_info_20260117.log
├── myapp_warn_20260117.log
└── myapp_error_20260117.log

Combined file (PerLevel: false):

logs/
└── myapp_20260117.log

New never fails. If the log directory cannot be created or a file cannot be opened, the problem is reported on stderr — once per distinct error, not once per record — and logging continues to the console alone.

Old files are kept indefinitely. Pruning them is left to logrotate or an equivalent.

Close log files

Call Close() on application shutdown to close open log files:

log := logger.NewWithFile("./logs")
defer log.Close()

Close is safe to call more than once, and on a logger without file output. It is not final either — a logger written to after Close reopens its file rather than dropping the record.

Console output

Console output goes to os.Stderr unless Writer says otherwise. Set it to capture output in tests, or to io.Discard to write only to file:

var buf bytes.Buffer
log := logger.New(logger.Options{Level: logger.Info, Writer: &buf})
Attach fields to every log entry
log := logger.With(logger.NewJSON(),
    slog.String("service", "fleet-api"),
    slog.String("version", "1.0.0"),
)
Set as global default
log := logger.NewJSON()
logger.SetDefault(log)

// Now the global slog functions use this logger
slog.Info("server started")
slog.Error("something failed", slog.Any("error", err))
Context propagation
// Attach a request-scoped logger to context
ctx := logger.WithContext(ctx, logger.With(log,
    slog.String("request_id", requestID),
    slog.String("user_id",    userID),
))

// Retrieve anywhere downstream
log := logger.FromContext(ctx)
log.Info("processing request")

FromContext returns a logger wrapping the slog default when the context carries none, so it is always safe to call.

Options
Field Type Default Description
Level slog.Level logger.Info Minimum log level
JSON bool false JSON output format
AddSource bool false Include source file and line number
Writer io.Writer os.Stderr Destination for console output
File *FileOptions nil File logging — disabled if nil
FileOptions
Field Type Default Description
Dir string "./logs" Directory for log files
Prefix string "app" File name prefix
PerLevel bool false Separate file per log level
Log levels
logger.Debug  // slog.LevelDebug
logger.Info   // slog.LevelInfo
logger.Warn   // slog.LevelWarn
logger.Error  // slog.LevelError

secrets

Fetches sensitive values from a pluggable source: environment variables in development, a managed secret store in production. Defines one interface so that swapping backends is a single line where the source is constructed.

import "codeberg.org/obadness/go-toolkit/secrets"
The interface
type Source interface {
    Secret(ctx context.Context, key string) (string, error)
}

A source returns an error wrapping secrets.ErrNotFound when the key is absent, and a distinct error for transport or authentication failures. That distinction is the point: the first is a configuration error an operator can fix, the second is an outage worth retrying.

password, err := src.Secret(ctx, "DB_PASSWORD")
switch {
case errors.Is(err, secrets.ErrNotFound):
    log.Fatal("DB_PASSWORD is not configured")
case err != nil:
    log.Fatal("secret store unreachable: ", err)
}
Env — the default source

Reads from environment variables. The zero value is usable.

src := secrets.Env{}

// Or namespace the variables: "DB_PASSWORD" reads MYAPP_DB_PASSWORD
src := secrets.Env{Prefix: "MYAPP_"}

A variable set to an empty value is reported as ErrNotFound. This is a deliberate divergence from env.Parse, which treats an empty value as a value: a blank feature flag is meaningful, a blank signing key is not, and an application that boots with one is worse off than one that refuses to start.

Env does not read .env files — load one first with the env package, which keeps secrets free of file handling.

Cached — TTL caching

A decorator, so caching behaves identically whichever source is underneath.

// Cache forever — the right default for secrets read once at startup
src := secrets.NewCached(backend, 0)

// Or bound how stale a value can be after a rotation
src := secrets.NewCached(backend, 15*time.Minute)

// Drop one value when you learn out-of-band that it rotated
src.Invalidate("DB_PASSWORD")
  • Only successful fetches are cached. A failure is never memoised, so a transient outage at boot cannot poison the cache for the lifetime of the process.
  • Safe for concurrent use. Concurrent misses on the same key each reach the source; that duplication is accepted rather than adding a singleflight dependency.
File — a secret per file
src := secrets.File{Dir: "/etc/myapp/secrets"}
tok, err := src.Secret(ctx, "TOKEN")   // reads /etc/myapp/secrets/token

For a deployment that mounts credentials rather than passing them in the environment: a systemd credential, a Docker secret, a Kubernetes volume.

The key is lowercased to form the filename and may not contain a path separator, so it cannot escape Dir. Contents are trimmed, so the newline left by openssl rand -hex 32 > token does not become part of the value. A missing or empty file is ErrNotFound, matching Env.

A file readable by group or other is refused, not served — a secret anyone on the host can read has to be assumed leaked, and that error is deliberately not ErrNotFound, so a MultiSource stops rather than quietly falling through to a weaker source.

MultiSource — ordered fallback

Tries each source in order and returns the first hit. For a deployment that reads most secrets from a managed store but allows a local override.

src := secrets.MultiSource{secrets.Env{}, managedStore}

Only ErrNotFound advances to the next source. Any other error stops the chain, so an authentication failure cannot quietly fall through to a weaker source. An empty MultiSource returns ErrNotFound rather than an empty value.

Backends

AWS Secrets Manager and HashiCorp Vault backends live in their own module, so that go-toolkit stays free of SDK dependencies. They satisfy the same Source interface:

var src secrets.Source = secrets.Env{}
if env.IsProd() {
    src = secrets.NewCached(awsbackend.New(client, awsbackend.WithPrefix("myapp/prod/")), 0)
}
Two rules
  1. Secrets only. A host or a timeout routed through a secret store is a wasted call and, against a paid API, a charge. Use env for plain configuration. The package does not enforce this.
  2. Fetch at startup. Read secrets once into your config struct and hold them for the lifetime of the process. Do not call Secret() per request.
Out of scope
  • No .env loading — that is the env package's job.
  • No rotation handling beyond a TTL and Invalidate. If a credential rotates, re-reading it and reconnecting is the caller's job.
  • No logging. The package never logs a fetched value, and errors name the key, never the value.
Consumer pattern

The config loader takes a secrets.Source and uses it only for sensitive values:

func Load(ctx context.Context, src secrets.Source) (Config, error) {
    dbPassword, err := src.Secret(ctx, "DB_PASSWORD")
    if err != nil {
        return Config{}, err
    }
    jwtSecret, err := src.Secret(ctx, "JWT_SECRET")
    if err != nil {
        return Config{}, err
    }

    return Config{
        DB: DB{
            Host:     env.Or(env.Parse, "DB_HOST", "localhost"), // not a secret
            Name:     env.Must(env.Parse("DB_NAME")),            // not a secret
            Password: dbPassword,                                // secret
        },
        Auth: Auth{JWTSecret: jwtSecret},
    }, nil
}

Typical setup

import (
    "codeberg.org/obadness/go-toolkit/env"
    "codeberg.org/obadness/go-toolkit/logger"
)

func main() {
    // Optional in production, where variables are injected by the runtime
    if err := env.LoadIfExists(".env"); err != nil {
        log.Fatal(err)
    }

    // Logger — JSON + file in production, text in development
    var log *logger.Logger
    if env.IsProd() {
        log = logger.NewWithFile("./logs")
    } else {
        log = logger.NewDefault()
    }
    defer log.Close()
    logger.SetDefault(log)

    // Config — Must for what the program cannot run without, Or for the rest
    var (
        dsn  = env.Must(env.Parse("DATABASE_URL"))
        host = env.Or(env.Parse,    "HOST", "0.0.0.0")
        port = env.Or(env.ParseInt, "PORT", 8080)
    )

    log.Info("starting server",
        slog.String("host", host),
        slog.Int("port",    port),
        slog.Bool("prod",   env.IsProd()),
    )
}

Project structure

go-toolkit/
├── go.mod
├── env/
│   ├── env.go        # Load, Parse, typed getters, Or, Must, IsDev, IsProd
│   └── env_test.go
├── logger/
│   ├── logger.go     # New, NewDefault, NewJSON, NewWithFile, SetDefault, With, FromContext
│   └── logger_test.go
└── secrets/
    ├── secrets.go    # Source, ErrNotFound, MultiSource
    ├── env.go        # Env — environment variable source
    ├── cache.go      # Cached — TTL caching decorator
    └── *_test.go

Tests

go test ./...
go test -race -cover ./...

License

MIT — Copyright (c) 2026 Jeremy Obado. See LICENSE.

Directories

Path Synopsis
Package env loads environment variables from a .env file and reads them back as typed values.
Package env loads environment variables from a .env file and reads them back as typed values.
Package logger wraps log/slog with console output, daily rotating log files and context propagation.
Package logger wraps log/slog with console output, daily rotating log files and context propagation.
Package secrets fetches application secrets from a pluggable source: environment variables in development, a managed secret store in production.
Package secrets fetches application secrets from a pluggable source: environment variables in development, a managed secret store in production.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL