Env var parsing

This commit is contained in:
Viktor Sokolov
2025-09-19 21:27:28 +02:00
parent 546e8159b0
commit 32f107554a
4 changed files with 237 additions and 17 deletions

80
env/env.go vendored Normal file
View File

@@ -0,0 +1,80 @@
package env
import (
"fmt"
"os"
)
// ParseFunc is a function type for parsing environment variable values
type ParseFunc[T any] func(string) (T, error)
// ValidateFunc is a function type for validating parsed environment variable values
type ValidateFunc[T any] func(T) bool
// generic number (add more types if needed)
type number = interface {
~int | ~int64 | ~float32 | ~float64
}
// EnvVar represents an environment variable with its metadata
type EnvVar[T any] struct {
name string // ENV variable name
description string // Description for help message
formatDesc string // Description of the format
def T // Default value (if any)
parse ParseFunc[T] // Function to parse the value from string
validate []ValidateFunc[T] // Optional validation function(s)
}
// Define creates a new EnvVar with the given parameters
func Define[T any](name, description, formatDesc string, parse ParseFunc[T], def T, val ...ValidateFunc[T]) EnvVar[T] {
return EnvVar[T]{
name: name,
description: description,
parse: parse,
formatDesc: formatDesc,
def: def,
validate: val,
}
}
// Get retrieves the value of the environment variable, parses it,
// validates it, and sets it to the provided pointer.
func (e EnvVar[T]) Get(value *T) (err error) {
val, exists := os.LookupEnv(e.name)
// First, let's fill with a value (either default or parsed)
if !exists {
*value = e.def
return nil
} else {
*value, err = e.parse(val)
if err != nil {
return fmt.Errorf("error parsing %s (required: %v): %v", e.name, e.formatDesc, err)
}
}
// Then, validate the value
for _, validate := range e.validate {
if !validate(*value) {
return fmt.Errorf("invalid value for %s (required: %v): %v", e.name, e.formatDesc, err)
}
}
return nil
}
// Default returns the default value of the environment variable
func (e EnvVar[T]) Default() T {
return e.def
}
// NotEmpty is a validation function that checks if a string is not empty
func NotEmpty(v string) bool {
return v != ""
}
// Positive is a validation function that checks if a number is greater than zero
func Positive[T number](v T) bool {
return v > 0
}

60
env/parsers.go vendored Normal file
View File

@@ -0,0 +1,60 @@
package env
import (
"strconv"
"time"
)
// String parses an environment variable as a string
func String(value string) (string, error) {
return value, nil
}
// Int parses an environment variable as an integer
func Int(value string) (int, error) {
return strconv.Atoi(value)
}
// Duration parses an environment variable as a time.Duration in seconds
func DurationSec(value string) (time.Duration, error) {
sec, err := strconv.Atoi(value)
if err != nil {
return 0, err
}
return time.Duration(sec) * time.Second, nil
}
// func Float(i *float64, name string) {
// if env, err := strconv.ParseFloat(os.Getenv(name), 64); err == nil {
// *i = env
// }
// }
// func MegaInt(f *int, name string) {
// if env, err := strconv.ParseFloat(os.Getenv(name), 64); err == nil {
// *f = int(env * 1000000)
// }
// }
// func String(s *string, name string) {
// if env := os.Getenv(name); len(env) > 0 {
// *s = env
// }
// }
// func StringSliceSep(s *[]string, name, sep string) {
// if env := os.Getenv(name); len(env) > 0 {
// parts := strings.Split(env, sep)
// for i, p := range parts {
// parts[i] = strings.TrimSpace(p)
// }
// *s = parts
// return
// }
// *s = []string{}
// }

View File

@@ -2,14 +2,27 @@ package processing
import (
"errors"
"fmt"
"strings"
"github.com/imgproxy/imgproxy/v3/config"
"github.com/imgproxy/imgproxy/v3/ensure"
"github.com/imgproxy/imgproxy/v3/env"
"github.com/imgproxy/imgproxy/v3/imagetype"
"github.com/imgproxy/imgproxy/v3/vips"
log "github.com/sirupsen/logrus"
)
var (
preferredFormats = env.Define(
"IMGPROXY_PREFERRED_FORMATS",
"Preferred image formats, comma-separated",
"jpeg, png, webp, tiff, avif, heic, gif, jp2", // what else?
parsePreferredFormats,
[]imagetype.Type{imagetype.JPEG, imagetype.PNG, imagetype.GIF},
)
)
// Config holds pipeline-related configuration.
type Config struct {
PreferredFormats []imagetype.Type
@@ -22,11 +35,7 @@ type Config struct {
func NewDefaultConfig() Config {
return Config{
WatermarkOpacity: 1,
PreferredFormats: []imagetype.Type{
imagetype.JPEG,
imagetype.PNG,
imagetype.GIF,
},
PreferredFormats: preferredFormats.Default(),
}
}
@@ -37,7 +46,10 @@ func LoadConfigFromEnv(c *Config) (*Config, error) {
c.WatermarkOpacity = config.WatermarkOpacity
c.DisableShrinkOnLoad = config.DisableShrinkOnLoad
c.UseLinearColorspace = config.UseLinearColorspace
c.PreferredFormats = config.PreferredFormats
if err := preferredFormats.Get(&c.PreferredFormats); err != nil {
return nil, err
}
return c, nil
}
@@ -68,3 +80,23 @@ func (c *Config) Validate() error {
return nil
}
func parsePreferredFormats(s string) ([]imagetype.Type, error) {
parts := strings.Split(s, ",")
it := make([]imagetype.Type, 0, len(parts))
for _, p := range parts {
part := strings.TrimSpace(p)
// For every part passed through the environment variable,
// check if it matches any of the image types defined in
// the imagetype package or return error.
t, ok := imagetype.GetTypeByName(part)
if !ok {
return nil, fmt.Errorf("unknown image format: %s", part)
}
it = append(it, t)
}
return it, nil
}

View File

@@ -8,12 +8,49 @@ import (
"github.com/imgproxy/imgproxy/v3/config"
"github.com/imgproxy/imgproxy/v3/ensure"
"github.com/imgproxy/imgproxy/v3/env"
"github.com/imgproxy/imgproxy/v3/server/responsewriter"
)
var (
bind = env.Define(
"IMGPROXY_BIND",
"Address and port to bind to",
"host:port, not empty",
env.String,
":8080",
env.NotEmpty,
)
network = env.Define(
"IMGPROXY_NETWORK",
"Network type",
"tcp/unix/udp",
env.String,
"tcp",
)
maxClients = env.Define(
"IMGPROXY_MAX_CLIENTS",
"Maximum number of concurrent clients",
"number > 0",
env.Int,
2048,
env.Positive,
)
readRequestTimeout = env.Define(
"IMGPROXY_READ_REQUEST_TIMEOUT",
"Timeout for reading requests",
"seconds > 0",
env.DurationSec,
time.Second*10,
env.Positive,
)
)
// Config represents HTTP server config
type Config struct {
Listen string // Address to listen on
Network string // Network type (tcp, unix)
Bind string // Bind address
PathPrefix string // Path prefix for the server
@@ -37,10 +74,10 @@ type Config struct {
// NewDefaultConfig returns default config values
func NewDefaultConfig() Config {
return Config{
Network: "tcp",
Bind: ":8080",
Network: network.Default(),
Bind: bind.Default(),
PathPrefix: "",
MaxClients: 2048,
MaxClients: maxClients.Default(),
ReadRequestTimeout: 10 * time.Second,
KeepAliveTimeout: 10 * time.Second,
GracefulTimeout: 20 * time.Second,
@@ -60,11 +97,22 @@ func NewDefaultConfig() Config {
func LoadConfigFromEnv(c *Config) (*Config, error) {
c = ensure.Ensure(c, NewDefaultConfig)
c.Network = config.Network
c.Bind = config.Bind
err := errors.Join(
network.Get(&c.Network),
bind.Get(&c.Bind),
maxClients.Get(&c.MaxClients),
readRequestTimeout.Get(&c.ReadRequestTimeout),
)
if err != nil {
return nil, err
}
// c.Network = config.Network
// c.Bind = config.Bind
c.PathPrefix = config.PathPrefix
c.MaxClients = config.MaxClients
c.ReadRequestTimeout = time.Duration(config.ReadRequestTimeout) * time.Second
// c.MaxClients = config.MaxClients
// c.ReadRequestTimeout = time.Duration(config.ReadRequestTimeout) * time.Second
c.KeepAliveTimeout = time.Duration(config.KeepAliveTimeout) * time.Second
c.GracefulTimeout = time.Duration(config.GracefulStopTimeout) * time.Second
c.CORSAllowOrigin = config.AllowOrigin
@@ -88,9 +136,9 @@ func (c *Config) Validate() error {
return errors.New("bind address is not defined")
}
if c.MaxClients < 0 {
return fmt.Errorf("max clients number should be greater than or equal 0, now - %d", c.MaxClients)
}
// if c.MaxClients < 0 {
// return fmt.Errorf("max clients number should be greater than or equal 0, now - %d", c.MaxClients)
// }
if c.ReadRequestTimeout <= 0 {
return fmt.Errorf("read request timeout should be greater than 0, now - %d", c.ReadRequestTimeout)