From 32f107554ae4c840527a066f26e31ec2aa5b6d8c Mon Sep 17 00:00:00 2001 From: Viktor Sokolov Date: Fri, 19 Sep 2025 21:27:28 +0200 Subject: [PATCH] Env var parsing --- env/env.go | 80 ++++++++++++++++++++++++++++++++++++++++++++ env/parsers.go | 60 +++++++++++++++++++++++++++++++++ processing/config.go | 44 ++++++++++++++++++++---- server/config.go | 70 ++++++++++++++++++++++++++++++++------ 4 files changed, 237 insertions(+), 17 deletions(-) create mode 100644 env/env.go create mode 100644 env/parsers.go diff --git a/env/env.go b/env/env.go new file mode 100644 index 00000000..b95e9f9c --- /dev/null +++ b/env/env.go @@ -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 +} diff --git a/env/parsers.go b/env/parsers.go new file mode 100644 index 00000000..6950e874 --- /dev/null +++ b/env/parsers.go @@ -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{} +// } diff --git a/processing/config.go b/processing/config.go index ea4dab2a..146c5c40 100644 --- a/processing/config.go +++ b/processing/config.go @@ -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 +} diff --git a/server/config.go b/server/config.go index f528a055..7af33584 100644 --- a/server/config.go +++ b/server/config.go @@ -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)