Microservices oriented 12-factor Go library for parsing environment variables and command line flags to arbitrary config struct using struct tags to define default values and to override flag names and environment variables' names.
This package in intended to be used to parse command line arguments and environment variables into an arbitrary config struct. This struct may contain multiple nested structs, they all will be processed recursively. Names of the flags and environment variables are automatically generated. Flags will be kebab case of the field name eventually preceded by parent fields in case of nested structs. Names of environment variables will be similar, but additionally prefixed with command name and then snake and upper cased. Description of each flag will also be automatically generated in a human friendly way as much as possible. Additionally, you may override these auto-generated names using the struct tags and you also may define default value.
- flag - override generated flag name
- env - override generated environment variable name
- help - override generated flag description
- def - override default (zero) value
- req - require the value to be supplied by environment variable or command line flag
- cmd - scope a field or nested struct to comma-separated top-level command names
Besides the types supported by flag package, this package provides additional types:
- bee.StringSlice - doesn't support multiple flags but instead supports comma separated strings, i.e. "foo,bar"
- bee.IntSlice - doesn't support multiple flags but instead supports comma separated integers, i.e. "5,-8,0"
- bee.URL
- bee.Time - RFC3339 time
User-defined named scalar types, such as type Level string, are not supported.
Fields of these types that previously panicked during parsing now return an error
wrapping bee.ErrUnsupportedType. Supported types such as time.Duration and
the bee types listed above remain supported.
- command line options
- environment variables
- default values
Fields tagged with req must be supplied by the user through either an
environment variable or command line flag. A field cannot use both req and
def, because a default value would satisfy the field without user input.
Validation failures return parse errors through the configured
flag.ErrorHandling mode.
req means the value must be supplied by environment variable or flag.
nonzero means the final parsed value, after defaults/env/flags, must not be zero.
| Tag | Applies To | Meaning |
|---|---|---|
min |
numbers, time.Duration |
Minimum final value |
max |
numbers, time.Duration |
Maximum final value |
oneof |
strings, numbers, time.Duration |
Comma-separated allowed values; whitespace is trimmed |
len |
strings, bee.StringSlice, bee.IntSlice |
Exact length |
minlen |
strings, bee.StringSlice, bee.IntSlice |
Minimum length |
maxlen |
strings, bee.StringSlice, bee.IntSlice |
Maximum length |
regex |
strings | Regular expression the value must match |
prefix |
strings, bee.URL |
Comma-separated allowed prefixes; whitespace is trimmed |
suffix |
strings, bee.URL |
Comma-separated allowed suffixes; whitespace is trimmed |
nonzero |
all supported types | Final parsed value must not be the zero value |
Run go test -v to see examples output.
bee.New is the entry point for services and operational commands. It creates a
typed *bee.App[T] that owns config parsing, logging, application context,
supervised goroutines, signal handling, and shutdown cleanup. A typical app:
- defines a config struct with
flag,env,help, anddeftags; - calls
bee.New(name, &cfg, options...); - accesses app-level runtime state through
app.Cfg,app.Log, andapp.Ctx; - registers command trees with
app.Cmd(...).Cmd(...); - uses
ctx.Cfg,ctx.Log, andctx.Ctxinside handlers; - starts long-running work with
ctx.Go; - starts HTTP servers with
ctx.HTTPServer; - registers cleanup with
ctx.Register; - stops the app with
ctx.Exit; - calls
app.Run().
package main
import (
"log/slog"
"net/http"
"os"
"time"
"go.acim.net/bee"
)
type Config struct {
HTTP struct {
Addr string `def:":8080"`
}
}
func main() {
cfg := Config{}
app := bee.New("maia", &cfg,
bee.WithLogger(slog.New(slog.NewJSONHandler(os.Stdout, nil))),
bee.WithShutdownTimeout(30*time.Second),
)
start := app.Cmd("start", "Start services")
start.Cmd("api", "Run HTTP API", func(ctx *bee.Ctx[Config]) error {
server := &http.Server{
Addr: ctx.Cfg.HTTP.Addr,
}
ctx.HTTPServer("http api", server)
ctx.Log.Info("api started", "addr", ctx.Cfg.HTTP.Addr)
return nil
})
app.Run()
}Command nodes can be nested to build command trees:
start := app.Cmd("start", "Start services")
start.Cmd("api", "Run HTTP API", runAPI)
start.Cmd("worker", "Run worker", runWorker)
app.Cmd("migrate", "Run migrations").Cmd("up", "Apply migrations", migrateUp)Users then run:
maia start api
maia start worker
maia migrate up
Register a root handler for invocations without a subcommand:
app.Root("Run service", runService)
app.Run()The root handler can be used alongside named commands:
app := bee.New("maia", &cfg)
app.Root("Run service", runService)
app.Cmd("admin", "Run an admin operation", runAdmin)
app.Run()Empty and flags-only invocations run the root handler, while named commands
continue to dispatch normally. Top-level help still prints usage without
running the root handler. If WithDefaultCommand is configured, its named
command takes precedence over the root handler.
Use cmd when a shared config contains settings that only some commands need:
type Config struct {
LogLevel string `def:"INFO"` // global
AuthCodeHMACKey string `cmd:"api" req:"" nonzero:""` // API only
DatabaseURL string `cmd:"api,sender" req:"" nonzero:""` // shared
}Register both api and sender with app.Cmd. Running sender then requires
DatabaseURL, but never reads, defaults, registers a flag for, or validates
AuthCodeHMACKey. Inactive fields retain their original values. Passing an
inactive flag is an unknown-flag error.
A scope on a nested struct applies to all its descendants. An explicit child scope can narrow its parent's scope:
type Config struct {
LogLevel string `def:"INFO"`
Server struct {
Port int `def:"8080" min:"1" max:"65535"`
TLS struct {
Certificate string `req:"" nonzero:""`
} `cmd:"serve"`
} `cmd:"serve,import"`
}
cfg := Config{}
app := bee.New("maia", &cfg, bee.WithDefaultCommand("serve something"))
serve := app.Cmd("serve", "Serve") // the parent need not have a handler
serve.Cmd("something", "First server", runSomething)
serve.Cmd("something-else", "Second server", runSomethingElse)
app.Cmd("import", "Import data", runImport)
app.Run()Both maia serve something and maia serve something-else activate all the
Server fields. maia import activates Server.Port but leaves Server.TLS
untouched. A runnable serve parent uses the same scope as its descendants.
Explicit and default commands behave identically, including nested defaults.
A root handler has no command group and receives global fields only.
Only the top-level name selects the scope: cmd:"serve" applies to
maia serve something, but not to maia run serve, whose group is run.
Command scopes do not affect option names: Server.Port still uses
-server-port and MAIA_SERVER_PORT; explicit flag and env overrides also
stay the same. Comma-separated scope names allow surrounding whitespace.
Every name must exactly identify a registered top-level command. Matching is
literal: a registered name such as a/b or * is supported, but / does not
separate command levels and * never matches other names. Full command paths
and wildcard matching are unsupported. Empty entries, unknown names (including
child-only names), and child scopes outside the parent scope are errors, even
on inactive fields. Diagnostics include the nested field path and
invalid scope. All scope declarations are checked before configuration values
are applied.
Command help shows global fields and fields active for that command's group,
without requiring runtime values. Top-level -help shows global flags and the
command overview, even when a default command is configured. Existing validation
tags, including the incompatibility of req and def, apply to active fields.
This feature changes no public Go signatures. Configurations without cmd tags
keep their existing behavior. To migrate a command-specific secret, add its
cmd scope, remove any dummy def, and use req:"" nonzero:"". Applications
that already use cmd tags for another purpose must remove or adapt those tags,
since Bee now interprets them.
ctx.HTTPServer starts the server as a supervised goroutine. Cancellation stops
intake and drains active requests using a fresh context. The drain timeout
falls back to WithShutdownTimeout (five seconds by default); give HTTP its
own budget when requests need longer:
app := bee.New("api", &cfg, bee.WithShutdownTimeout(5*time.Second))
app.Root("serve", func(ctx *bee.Ctx[Config]) error {
// Construct server and register dependencies here.
ctx.HTTPServer("http api", server, bee.WithHTTPDrainTimeout(130*time.Second))
return nil
})If the HTTP drain deadline expires, Bee cancels remaining request contexts,
closes their connections, and waits for handlers to return before closing
registered dependencies. The shutdown error is returned by RunE.
Request context values and client cancellation remain intact. Late requests
cannot enter the application handler once forced shutdown begins. Hijacked
connections remain the application's responsibility.
Registered closers run in reverse order after supervised work finishes. Each
closer receives its own WithShutdownTimeout budget; it is not a global process
deadline and does not forcibly stop a closer that ignores its context. Budget
for HTTP draining, finalization, and all sequential closers in the process
supervisor's termination grace. Handlers that ignore cancellation can keep Bee
waiting; the process supervisor owns the hard termination deadline.
Use ctx.Input(os.Stdin) instead of scanning stdin directly. It returns an
*Input implementing io.ReadCloser; cancellation unblocks pending pipe and
terminal reads and returns the context error. No additional signal handler or
reader goroutine is needed in the application.
input, err := ctx.Input(os.Stdin)
if err != nil {
return err
}
defer func() {
if err := input.Close(); err != nil {
ctx.Log.Warn("close input", bee.SlogError(err))
}
}()
scanner := bufio.NewScanner(input)
for scanner.Scan() {
// Handle scanner.Text().
}
if errors.Is(scanner.Err(), context.Canceled) {
return nil // Treat cancellation while waiting for input as a clean exit.
}
return scanner.Err()Close is idempotent and leaves the original file open. Input temporarily owns
reading and file-status flags: do not read, close, or change the original file
until Input has been closed. Duplicate descriptors share their read offset.
Linux, macOS, FreeBSD, OpenBSD, NetBSD, and DragonFly are supported; other
platforms return errors.ErrUnsupported. Regular files and /dev/null work
for redirected input, but a kernel disk read in progress cannot be interrupted.
Unsupported blocking devices are rejected instead of promising cancellation.
The pipe tests run in Go; terminal signal integration tests additionally use
Python 3's standard pty module and report a skip when Python is unavailable.
NewService has been removed in favor of the typed bee.New[T] API. Move
startup code into a Root or Command handler, replace direct config access
with ctx.Cfg, replace service logs with ctx.Log, replace manually owned
contexts with ctx.Ctx, replace manual goroutines with ctx.Go, and keep
shutdown callbacks on ctx.Register.
bee.Middlewares is a small helper for standard Go HTTP middleware:
type Middlewares []func(http.Handler) http.HandlerAdd appends middleware to the stack. Wrap starts with the supplied *http.ServeMux
and applies middleware in reverse index order, so requests execute in the same order
the middleware was added. If the stack is empty, Wrap returns the original mux.
For Add(first) followed by Add(second), request execution is:
first before -> second before -> handler -> second after -> first after.
var mws bee.Middlewares
mws.Add(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Println("first before")
next.ServeHTTP(w, r)
fmt.Println("first after")
})
})
mws.Add(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Println("second before")
next.ServeHTTP(w, r)
fmt.Println("second after")
})
})
mux := http.NewServeMux()
mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
fmt.Println("handler")
})
http.ListenAndServe(":8080", mws.Wrap(mux))For one request, this prints:
first before
second before
handler
second after
first after
Middlewares are plain net/http middleware functions, not bee-specific
route-aware middleware.
Global middleware should be used for cross-cutting behavior such as logging,
security headers, recovery, and request IDs. Anything passed through
mws.Wrap(mux) sits in front of the whole mux and runs before net/http
ServeMux route selection.
app.Root("Run service", func(ctx *bee.Ctx[Config]) error {
var mws bee.Middlewares
mws.Add(bee.SlogLogger(ctx.Log))
mws.Add(securityHeadersMiddleware)
mws.Add(recoveryMiddleware)
mws.Add(requestIDMiddleware)
mux := http.NewServeMux()
server := &http.Server{
Addr: ":8080",
Handler: mws.Wrap(mux),
}
ctx.HTTPServer("http api", server)
return nil
})To omit access logs for probe endpoints, configure exact paths:
mws.Add(bee.SlogLogger(ctx.Log, bee.WithSkipPaths([]string{"/health", "/ready"})))Skipped requests still reach their handlers, but produce no access log even if
the response is an error. Matching uses req.URL.Path, so query strings are
ignored and /health/ does not match /health. Nil or empty path lists skip
nothing; without the option, all requests are logged.
Route-specific behavior, such as auth or CORS for only part of the service, should usually wrap a sub-mux or handler before mounting it on the root mux:
mux := http.NewServeMux()
apiMux := http.NewServeMux()
apiMux.HandleFunc("GET /api/users", usersHandler)
mux.Handle("/api/", authMiddleware(apiMux))
server := &http.Server{
Addr: ":8080",
Handler: mws.Wrap(mux),
}Licensed under either of
- Apache License, Version 2.0 (LICENSE-APACHE or http://www.apache.org/licenses/LICENSE-2.0)
- MIT license (LICENSE-MIT or http://opensource.org/licenses/MIT)
at your option.
Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions.