Procházet zdrojové kódy

IMG-49: Introduced instance (#1512)

* Introduced instance

* Makefile changes
Victor Sokolov před 3 týdny
rodič
revize
2d9ad5c250

+ 48 - 48
.air.toml

@@ -3,61 +3,61 @@ testdata_dir = "testdata"
 tmp_dir = "tmp"
 
 [build]
-  args_bin = []
-  bin = "./tmp/main"
-  cmd = "go build -o ./tmp/main ."
-  delay = 1000
-  exclude_dir = ["assets", "tmp", "vendor", "testdata"]
-  exclude_file = []
-  exclude_regex = ["_test.go"]
-  exclude_unchanged = false
-  follow_symlink = false
-  full_bin = ""
-  include_dir = []
-  include_ext = ["go", "tpl", "tmpl", "html"]
-  include_file = [
-    "vips/vips.c",
-    "vips/vips.h",
-    "vips/source.c",
-    "vips/source.h",
-    "vips/icoload.c",
-    "vips/icosave.c",
-    "vips/ico.h",
-    "vips/bmpload.c",
-    "vips/bmpload.h",
-    "vips/bmp.h"
-  ]
-  kill_delay = "4s"
-  log = "build-errors.log"
-  poll = false
-  poll_interval = 0
-  post_cmd = []
-  pre_cmd = []
-  rerun = false
-  rerun_delay = 500
-  send_interrupt = true
-  stop_on_error = true
+args_bin = []
+bin = "./tmp/main"
+cmd = "make build -- -o ./tmp/main"
+delay = 1000
+exclude_dir = ["assets", "tmp", "vendor", "testdata"]
+exclude_file = []
+exclude_regex = ["_test.go"]
+exclude_unchanged = false
+follow_symlink = false
+full_bin = ""
+include_dir = []
+include_ext = ["go", "tpl", "tmpl", "html"]
+include_file = [
+  "vips/vips.c",
+  "vips/vips.h",
+  "vips/source.c",
+  "vips/source.h",
+  "vips/icoload.c",
+  "vips/icosave.c",
+  "vips/ico.h",
+  "vips/bmpload.c",
+  "vips/bmpload.h",
+  "vips/bmp.h",
+]
+kill_delay = "4s"
+log = "build-errors.log"
+poll = false
+poll_interval = 0
+post_cmd = []
+pre_cmd = []
+rerun = false
+rerun_delay = 500
+send_interrupt = true
+stop_on_error = true
 
 [color]
-  app = ""
-  build = "yellow"
-  main = "magenta"
-  runner = "green"
-  watcher = "cyan"
+app = ""
+build = "yellow"
+main = "magenta"
+runner = "green"
+watcher = "cyan"
 
 [log]
-  main_only = false
-  silent = false
-  time = false
+main_only = false
+silent = false
+time = false
 
 [misc]
-  clean_on_exit = false
+clean_on_exit = false
 
 [proxy]
-  app_port = 0
-  enabled = false
-  proxy_port = 0
+app_port = 0
+enabled = false
+proxy_port = 0
 
 [screen]
-  clear_on_rebuild = false
-  keep_scroll = true
+clear_on_rebuild = false
+keep_scroll = true

+ 1 - 1
.github/workflows/ci.yml

@@ -33,7 +33,7 @@ jobs:
       - name: Mark git workspace as safe
         run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
       - name: Test
-        run: go test -tags integration ./...
+        run: go test ./...
 
   lint:
     runs-on: ubuntu-latest

+ 1 - 10
.lefthook/pre-commit/lint

@@ -5,13 +5,4 @@ if ! git diff --staged --name-only | grep -qE ".*\.go$|\.golangci\.yml$"; then
   exit 0;
 fi
 
-if [ -x "$(which brew)" ]; then
-  export PKG_CONFIG_PATH="$PKG_CONFIG_PATH:$(brew --prefix libffi)/lib/pkgconfig"
-  export PKG_CONFIG_PATH="$PKG_CONFIG_PATH:$(brew --prefix libarchive)/lib/pkgconfig"
-  export PKG_CONFIG_PATH="$PKG_CONFIG_PATH:$(brew --prefix cfitsio)/lib/lib/pkgconfig"
-fi
-
-export CGO_LDFLAGS_ALLOW="-s|-w"
-export CGO_CFLAGS_ALLOW="-I|-Xpreprocessor"
-
-golangci-lint --build-tags integration run
+make lint

+ 1 - 15
.lefthook/pre-push/test

@@ -1,17 +1,3 @@
 #!/bin/sh
 
-if [ -x "$(which brew)" ]; then
-  export PKG_CONFIG_PATH="$PKG_CONFIG_PATH:$(brew --prefix libffi)/lib/pkgconfig"
-  export PKG_CONFIG_PATH="$PKG_CONFIG_PATH:$(brew --prefix libarchive)/lib/pkgconfig"
-  export PKG_CONFIG_PATH="$PKG_CONFIG_PATH:$(brew --prefix cfitsio)/lib/lib/pkgconfig"
-  export CGO_LDFLAGS="$CGO_LDFLAGS -Wl,-no_warn_duplicate_libraries"
-fi
-
-export CGO_LDFLAGS_ALLOW="-s|-w"
-export CGO_CFLAGS_ALLOW="-I|-Xpreprocessor"
-
-if [ -x "$(which gotestsum)" ]; then
-  gotestsum ./...
-else
-  go test -v ./...
-fi
+make test

+ 6 - 0
CHANGELOG.v4.md

@@ -12,3 +12,9 @@ if `IMGPROXY_USE_ETAG` is true.
 ### ❌ Removed
 
 - `Etag` calculations on the imgproxy side
+
+## 2025-09-09
+
+### ❌ Removed
+
+- `--keys`, `--salts` CLI args

+ 76 - 0
Makefile

@@ -0,0 +1,76 @@
+# imgproxy Makefile
+
+BINARY := imgproxy
+
+GOCMD := go
+GOBUILD := $(GOCMD) build
+GOCLEAN := $(GOCMD) clean
+GOTEST := $(GOCMD) test
+GOFMT := gofmt
+GOLINT := golangci-lint
+GOTESTSUM := gotestsum
+SRCDIR := ./cli
+BREW_PREFIX :=
+
+# Common environment setup for CGO builds
+ifneq ($(shell which brew),)
+	BREW_PREFIX := $(shell brew --prefix)
+endif
+
+# Export CGO environment variables
+export CGO_LDFLAGS_ALLOW := -s|-w
+
+# Library paths for Homebrew-installed libraries on macOS
+ifdef BREW_PREFIX
+	export PKG_CONFIG_PATH := $(PKG_CONFIG_PATH):$(shell brew --prefix libffi)/lib/pkgconfig
+	export PKG_CONFIG_PATH := $(PKG_CONFIG_PATH):$(shell brew --prefix libarchive)/lib/pkgconfig
+	export PKG_CONFIG_PATH := $(PKG_CONFIG_PATH):$(shell brew --prefix cfitsio)/lib/pkgconfig
+
+	export CGO_LDFLAGS := $(CGO_LDFLAGS) -Wl,-no_warn_duplicate_libraries
+endif
+
+# Default target
+.PHONY: all
+all: build
+
+# Build the binary. If -o is not provided, it defaults to $(BINARY).
+#
+# Usage:
+#	make build -- -o output_name
+.PHONY: build
+build:
+	@$(GOBUILD) -o $(BINARY) $(filter-out $@,$(MAKECMDGOALS)) $(SRCDIR); \
+
+# Clean
+.PHONY: clean
+clean:
+	echo $$PKG_CONFIG_PATH
+	@$(GOCLEAN)
+	rm -f $(BINARY)
+
+# Run tests
+#
+# Usage:
+#	make test -- -run FooTest
+.PHONY: test
+test:
+ifneq ($(shell which $(GOTESTSUM)),)
+	@$(GOTESTSUM) ./...
+else
+	@$(GOTEST) -v ./...
+endif
+
+# Format code
+.PHONY: fmt
+fmt:
+	@$(GOFMT) -s -w .
+
+# Lint code (requires golangci-lint installed)
+.PHONY: lint
+lint:
+	@$(GOLINT) run
+
+# Make any unknown target do nothing to avoid "up to date" messages
+.PHONY: FORCE
+%: FORCE
+	@:

+ 6 - 1
auximageprovider/static_provider.go

@@ -21,7 +21,12 @@ func (s *staticProvider) Get(_ context.Context, po *options.ProcessingOptions) (
 }
 
 // NewStaticFromTriple creates a new ImageProvider from either a base64 string, file path, or URL
-func NewStaticProvider(ctx context.Context, c *StaticConfig, desc string, idf *imagedata.Factory) (Provider, error) {
+func NewStaticProvider(
+	ctx context.Context,
+	c *StaticConfig,
+	desc string,
+	idf *imagedata.Factory,
+) (Provider, error) {
 	var (
 		data    imagedata.ImageData
 		headers = make(http.Header)

+ 7 - 4
healthcheck.go → cli/healthcheck.go

@@ -10,9 +10,11 @@ import (
 
 	"github.com/imgproxy/imgproxy/v3/config"
 	"github.com/imgproxy/imgproxy/v3/config/configurators"
+	"github.com/urfave/cli/v3"
 )
 
-func healthcheck() int {
+// healthcheck performs a healthcheck on a running imgproxy instance
+func healthcheck(ctx context.Context, c *cli.Command) error {
 	network := config.Network
 	bind := config.Bind
 	pathprefix := config.PathPrefix
@@ -32,7 +34,7 @@ func healthcheck() int {
 	res, err := httpc.Get(fmt.Sprintf("http://imgproxy%s/health", pathprefix))
 	if err != nil {
 		fmt.Fprintln(os.Stderr, err.Error())
-		return 1
+		return cli.Exit(err, 1)
 	}
 	defer res.Body.Close()
 
@@ -40,8 +42,9 @@ func healthcheck() int {
 	fmt.Fprintln(os.Stderr, string(msg))
 
 	if res.StatusCode != 200 {
-		return 1
+		err := fmt.Errorf("healthcheck failed: %s", msg)
+		return cli.Exit(err, 1)
 	}
 
-	return 0
+	return nil
 }

+ 80 - 0
cli/main.go

@@ -0,0 +1,80 @@
+package main
+
+import (
+	"context"
+	"fmt"
+	"log"
+	"os"
+	"os/signal"
+	"syscall"
+
+	"github.com/imgproxy/imgproxy/v3"
+	"github.com/imgproxy/imgproxy/v3/version"
+	"github.com/urfave/cli/v3"
+)
+
+// ver prints the imgproxy version and runs the main application
+func ver(ctx context.Context, c *cli.Command) error {
+	fmt.Println(version.Version)
+	return nil
+}
+
+// run starts the imgproxy server
+func run(ctx context.Context, cmd *cli.Command) error {
+	// NOTE: for now, this flag is loaded in config.go package
+
+	// presets := cmd.String("presets")
+
+	if err := imgproxy.Init(); err != nil {
+		return err
+	}
+	defer imgproxy.Shutdown()
+
+	cfg, err := imgproxy.LoadConfigFromEnv(nil)
+	if err != nil {
+		return err
+	}
+
+	ctx, _ = signal.NotifyContext(ctx, syscall.SIGINT, syscall.SIGTERM)
+
+	instance, err := imgproxy.New(ctx, cfg)
+	if err != nil {
+		return err
+	}
+
+	if err := instance.StartServer(ctx); err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func main() {
+	cmd := &cli.Command{
+		Name:  "imgproxy",
+		Usage: "Fast and secure standalone server for resizing and converting remote images",
+		Flags: []cli.Flag{
+			&cli.StringFlag{
+				Name:  "presets",
+				Usage: "path of the file with presets",
+			},
+		},
+		Action: run,
+		Commands: []*cli.Command{
+			{
+				Name:   "version",
+				Usage:  "print the version",
+				Action: ver,
+			},
+			{
+				Name:   "health",
+				Usage:  "perform a healthcheck on a running imgproxy instance",
+				Action: healthcheck,
+			},
+		},
+	}
+
+	if err := cmd.Run(context.Background(), os.Args); err != nil {
+		log.Fatal(err)
+	}
+}

+ 86 - 0
config.go

@@ -0,0 +1,86 @@
+package imgproxy
+
+import (
+	"github.com/imgproxy/imgproxy/v3/auximageprovider"
+	"github.com/imgproxy/imgproxy/v3/ensure"
+	"github.com/imgproxy/imgproxy/v3/fetcher"
+	processinghandler "github.com/imgproxy/imgproxy/v3/handlers/processing"
+	"github.com/imgproxy/imgproxy/v3/handlers/stream"
+	"github.com/imgproxy/imgproxy/v3/headerwriter"
+	"github.com/imgproxy/imgproxy/v3/semaphores"
+	"github.com/imgproxy/imgproxy/v3/server"
+	"github.com/imgproxy/imgproxy/v3/transport"
+)
+
+// Config represents an instance configuration
+type Config struct {
+	HeaderWriter      headerwriter.Config
+	Semaphores        semaphores.Config
+	FallbackImage     auximageprovider.StaticConfig
+	WatermarkImage    auximageprovider.StaticConfig
+	Transport         transport.Config
+	Fetcher           fetcher.Config
+	ProcessingHandler processinghandler.Config
+	StreamHandler     stream.Config
+	Server            server.Config
+}
+
+// NewDefaultConfig creates a new default configuration
+func NewDefaultConfig() Config {
+	return Config{
+		HeaderWriter:      headerwriter.NewDefaultConfig(),
+		Semaphores:        semaphores.NewDefaultConfig(),
+		FallbackImage:     auximageprovider.NewDefaultStaticConfig(),
+		WatermarkImage:    auximageprovider.NewDefaultStaticConfig(),
+		Transport:         transport.NewDefaultConfig(),
+		Fetcher:           fetcher.NewDefaultConfig(),
+		ProcessingHandler: processinghandler.NewDefaultConfig(),
+		StreamHandler:     stream.NewDefaultConfig(),
+		Server:            server.NewDefaultConfig(),
+	}
+}
+
+// LoadConfigFromEnv loads configuration from environment variables
+func LoadConfigFromEnv(c *Config) (*Config, error) {
+	c = ensure.Ensure(c, NewDefaultConfig)
+
+	var err error
+
+	if _, err = server.LoadConfigFromEnv(&c.Server); err != nil {
+		return nil, err
+	}
+
+	if _, err = auximageprovider.LoadFallbackStaticConfigFromEnv(&c.FallbackImage); err != nil {
+		return nil, err
+	}
+
+	if _, err = auximageprovider.LoadWatermarkStaticConfigFromEnv(&c.WatermarkImage); err != nil {
+		return nil, err
+	}
+
+	if _, err = headerwriter.LoadConfigFromEnv(&c.HeaderWriter); err != nil {
+		return nil, err
+	}
+
+	if _, err = semaphores.LoadConfigFromEnv(&c.Semaphores); err != nil {
+		return nil, err
+	}
+
+	if _, err = transport.LoadConfigFromEnv(&c.Transport); err != nil {
+		return nil, err
+	}
+
+	if _, err = fetcher.LoadConfigFromEnv(&c.Fetcher); err != nil {
+		return nil, err
+	}
+
+	if _, err = processinghandler.LoadConfigFromEnv(&c.ProcessingHandler); err != nil {
+		return nil, err
+	}
+
+	if _, err = stream.LoadConfigFromEnv(&c.StreamHandler); err != nil {
+		return nil, err
+	}
+
+	return c, nil
+}

+ 8 - 2
config/config.go

@@ -17,6 +17,14 @@ import (
 	"github.com/imgproxy/imgproxy/v3/version"
 )
 
+func init() {
+	// We need to reset config to defaults once on app start.
+	// Tests could perform config.Reset() to ensure a clean state where required.
+	// NOTE: this is temporary workaround until we finally move
+	// to Config objects everywhere
+	Reset()
+}
+
 type URLReplacement = configurators.URLReplacement
 
 var (
@@ -425,8 +433,6 @@ func Reset() {
 }
 
 func Configure() error {
-	Reset()
-
 	if port := os.Getenv("PORT"); len(port) > 0 {
 		Bind = fmt.Sprintf(":%s", port)
 	}

+ 1 - 1
docker/Dockerfile

@@ -5,7 +5,7 @@ FROM ghcr.io/imgproxy/imgproxy-base:${BASE_IMAGE_VERSION} AS build
 ENV CGO_ENABLED=1
 
 COPY . .
-RUN bash -c 'go build -v -ldflags "-s -w" -o /opt/imgproxy/bin/imgproxy'
+RUN bash -c 'go build -v -ldflags "-s -w" -o /opt/imgproxy/bin/imgproxy ./cli'
 
 # Remove unnecessary files
 RUN rm -rf /opt/imgproxy/lib/pkgconfig /opt/imgproxy/lib/cmake

+ 2 - 0
fetcher/config.go

@@ -13,8 +13,10 @@ import (
 type Config struct {
 	// UserAgent is the User-Agent header to use when fetching images.
 	UserAgent string
+
 	// DownloadTimeout is the timeout for downloading an image, in seconds.
 	DownloadTimeout time.Duration
+
 	// MaxRedirects is the maximum number of redirects to follow when fetching an image.
 	MaxRedirects int
 }

+ 3 - 0
go.mod

@@ -119,6 +119,7 @@ require (
 	github.com/cespare/xxhash/v2 v2.3.0 // indirect
 	github.com/cihub/seelog v0.0.0-20170130134532-f561c5e57575 // indirect
 	github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 // indirect
+	github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
 	github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
 	github.com/dustin/go-humanize v1.0.1 // indirect
 	github.com/eapache/queue/v2 v2.0.0-20230407133247-75960ed334e4 // indirect
@@ -174,6 +175,7 @@ require (
 	github.com/prometheus/common v0.65.0 // indirect
 	github.com/prometheus/procfs v0.17.0 // indirect
 	github.com/puzpuzpuz/xsync/v3 v3.5.1 // indirect
+	github.com/russross/blackfriday/v2 v2.1.0 // indirect
 	github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46 // indirect
 	github.com/secure-systems-lab/go-securesystemslib v0.9.0 // indirect
 	github.com/shabbyrobe/gocovmerge v0.0.0-20190829150210-3e036491d500 // indirect
@@ -182,6 +184,7 @@ require (
 	github.com/tinylib/msgp v1.3.0 // indirect
 	github.com/tklauser/go-sysconf v0.3.15 // indirect
 	github.com/tklauser/numcpus v0.10.0 // indirect
+	github.com/urfave/cli/v3 v3.4.1 // indirect
 	github.com/x448/float16 v0.8.4 // indirect
 	github.com/yusufpapurcu/wmi v1.2.4 // indirect
 	github.com/zeebo/errs v1.4.0 // indirect

+ 9 - 0
go.sum

@@ -44,6 +44,7 @@ github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mo
 github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 h1:oygO0locgZJe7PpYPXT5A29ZkwJaPqcva7BVeemZOZs=
 github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
 github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
+github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
 github.com/DarthSim/godotenv v1.3.1 h1:NMWdswlRx2M9uPY4Ux8p/Q/rDs7A97OG89fECiQ/Tz0=
 github.com/DarthSim/godotenv v1.3.1/go.mod h1:B3ySe1HYTUFFR6+TPyHyxPWjUdh48il0Blebg9p1cCc=
 github.com/DataDog/appsec-internal-go v1.13.0 h1:aO6DmHYsAU8BNFuvYJByhMKGgcQT3WAbj9J/sgAJxtA=
@@ -172,6 +173,8 @@ github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 h1:aQ3y1lwWyqYPiWZThqv
 github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8=
 github.com/corona10/goimagehash v1.1.0 h1:teNMX/1e+Wn/AYSbLHX8mj+mF9r60R1kBeqE9MkoYwI=
 github.com/corona10/goimagehash v1.1.0/go.mod h1:VkvE0mLn84L4aF8vCb6mafVajEb6QYMHl2ZJLn0mOGI=
+github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo=
+github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
@@ -394,6 +397,8 @@ github.com/richardartoul/molecule v1.0.1-0.20240531184615-7ca0df43c0b3 h1:4+LEVO
 github.com/richardartoul/molecule v1.0.1-0.20240531184615-7ca0df43c0b3/go.mod h1:vl5+MqJ1nBINuSsUI2mGgH79UweUT/B5Fy8857PqyyI=
 github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
 github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
+github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
+github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
 github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46 h1:GHRpF1pTW19a8tTFrMLUcfWwyC0pnifVo2ClaLq+hP8=
 github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46/go.mod h1:uAQ5PCi+MFsC7HjREoAz1BU+Mq60+05gifQSsHSDG/8=
 github.com/secure-systems-lab/go-securesystemslib v0.9.0 h1:rf1HIbL64nUpEIZnjLZ3mcNEL9NBPB0iuVjyxvq3LZc=
@@ -441,6 +446,10 @@ github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfj
 github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ=
 github.com/trimmer-io/go-xmp v1.0.0 h1:zY8bolSga5kOjBAaHS6hrdxLgEoYuT875xTy0QDwZWs=
 github.com/trimmer-io/go-xmp v1.0.0/go.mod h1:Aaptr9sp1lLv7UnCAdQ+gSHZyY2miYaKmcNVj7HRBwA=
+github.com/urfave/cli v1.22.17 h1:SYzXoiPfQjHBbkYxbew5prZHS1TOLT3ierW8SYLqtVQ=
+github.com/urfave/cli v1.22.17/go.mod h1:b0ht0aqgH/6pBYzzxURyrM4xXNgsoT/n2ZzwQiEhNVo=
+github.com/urfave/cli/v3 v3.4.1 h1:1M9UOCy5bLmGnuu1yn3t3CB4rG79Rtoxuv1sPhnm6qM=
+github.com/urfave/cli/v3 v3.4.1/go.mod h1:FJSKtM/9AiiTOJL4fJ6TbMUkxBXn7GO9guZqoZtpYpo=
 github.com/vmihailenco/msgpack/v4 v4.3.13 h1:A2wsiTbvp63ilDaWmsk2wjx6xZdxQOvpiNlKBGKKXKI=
 github.com/vmihailenco/msgpack/v4 v4.3.13/go.mod h1:gborTTJjAo/GWTqqRjrLCn9pgNN+NXzzngzBKDPIqw4=
 github.com/vmihailenco/tagparser v0.1.2 h1:gnjoVuB/kljJ5wICEEOpx98oXMWPLj22G67Vbd1qPqc=

+ 3 - 3
handlers/processing/handler.go

@@ -26,7 +26,7 @@ type Handler struct {
 	semaphores     *semaphores.Semaphores
 	fallbackImage  auximageprovider.Provider
 	watermarkImage auximageprovider.Provider
-	imageData      *imagedata.Factory
+	idf            *imagedata.Factory
 }
 
 // New creates new handler object
@@ -50,7 +50,7 @@ func New(
 		semaphores:     semaphores,
 		fallbackImage:  fi,
 		watermarkImage: wi,
-		imageData:      idf,
+		idf:            idf,
 	}, nil
 }
 
@@ -88,7 +88,7 @@ func (h *Handler) Execute(
 		monitoringMeta: mm,
 		semaphores:     h.semaphores,
 		hwr:            h.hw.NewRequest(),
-		idf:            h.imageData,
+		idf:            h.idf,
 	}
 
 	return req.execute(ctx)

+ 1 - 0
handlers/processing/handler_test.go

@@ -0,0 +1 @@
+package processing

+ 1 - 1
handlers/processing/request_methods.go

@@ -154,7 +154,7 @@ func (r *request) getFallbackImage(
 // processImage calls actual image processing
 func (r *request) processImage(ctx context.Context, originData imagedata.ImageData) (*processing.Result, error) {
 	defer monitoring.StartProcessingSegment(ctx, r.monitoringMeta.Filter(monitoring.MetaProcessingOptions))()
-	return processing.ProcessImage(ctx, originData, r.po, r.handler.watermarkImage, r.handler.imageData)
+	return processing.ProcessImage(ctx, originData, r.po, r.handler.watermarkImage, r.handler.idf)
 }
 
 // writeDebugHeaders writes debug headers (X-Origin-*, X-Result-*) to the response

+ 168 - 0
imgproxy.go

@@ -0,0 +1,168 @@
+package imgproxy
+
+import (
+	"context"
+	"time"
+
+	"github.com/imgproxy/imgproxy/v3/auximageprovider"
+	"github.com/imgproxy/imgproxy/v3/fetcher"
+	"github.com/imgproxy/imgproxy/v3/handlers"
+	processinghandler "github.com/imgproxy/imgproxy/v3/handlers/processing"
+	"github.com/imgproxy/imgproxy/v3/handlers/stream"
+	"github.com/imgproxy/imgproxy/v3/headerwriter"
+	"github.com/imgproxy/imgproxy/v3/imagedata"
+	"github.com/imgproxy/imgproxy/v3/memory"
+	"github.com/imgproxy/imgproxy/v3/monitoring/prometheus"
+	"github.com/imgproxy/imgproxy/v3/semaphores"
+	"github.com/imgproxy/imgproxy/v3/server"
+	"github.com/imgproxy/imgproxy/v3/transport"
+)
+
+const (
+	faviconPath = "/favicon.ico"
+	healthPath  = "/health"
+)
+
+// Imgproxy holds all the components needed for imgproxy to function
+type Imgproxy struct {
+	HeaderWriter      *headerwriter.Writer
+	Semaphores        *semaphores.Semaphores
+	FallbackImage     auximageprovider.Provider
+	WatermarkImage    auximageprovider.Provider
+	Fetcher           *fetcher.Fetcher
+	ProcessingHandler *processinghandler.Handler
+	StreamHandler     *stream.Handler
+	ImageDataFactory  *imagedata.Factory
+	Config            *Config
+}
+
+// New creates a new imgproxy instance
+func New(ctx context.Context, config *Config) (*Imgproxy, error) {
+	headerWriter, err := headerwriter.New(&config.HeaderWriter)
+	if err != nil {
+		return nil, err
+	}
+
+	ts, err := transport.New(&config.Transport)
+	if err != nil {
+		return nil, err
+	}
+
+	fetcher, err := fetcher.New(ts, &config.Fetcher)
+	if err != nil {
+		return nil, err
+	}
+
+	idf := imagedata.NewFactory(fetcher)
+
+	fallbackImage, err := auximageprovider.NewStaticProvider(ctx, &config.FallbackImage, "fallback", idf)
+	if err != nil {
+		return nil, err
+	}
+
+	watermarkImage, err := auximageprovider.NewStaticProvider(ctx, &config.WatermarkImage, "watermark", idf)
+	if err != nil {
+		return nil, err
+	}
+
+	semaphores, err := semaphores.New(&config.Semaphores)
+	if err != nil {
+		return nil, err
+	}
+
+	streamHandler, err := stream.New(&config.StreamHandler, headerWriter, fetcher)
+	if err != nil {
+		return nil, err
+	}
+
+	ph, err := processinghandler.New(
+		streamHandler, headerWriter, semaphores, fallbackImage, watermarkImage, idf, &config.ProcessingHandler,
+	)
+	if err != nil {
+		return nil, err
+	}
+
+	return &Imgproxy{
+		HeaderWriter:      headerWriter,
+		Semaphores:        semaphores,
+		FallbackImage:     fallbackImage,
+		WatermarkImage:    watermarkImage,
+		Fetcher:           fetcher,
+		StreamHandler:     streamHandler,
+		ProcessingHandler: ph,
+		ImageDataFactory:  idf,
+		Config:            config,
+	}, nil
+}
+
+// BuildRouter sets up the HTTP routes and middleware
+func (i *Imgproxy) BuildRouter() (*server.Router, error) {
+	r, err := server.NewRouter(&i.Config.Server)
+	if err != nil {
+		return nil, err
+	}
+
+	r.GET("/", handlers.LandingHandler)
+	r.GET("", handlers.LandingHandler)
+
+	r.GET(faviconPath, r.NotFoundHandler).Silent()
+	r.GET(healthPath, handlers.HealthHandler).Silent()
+	if i.Config.Server.HealthCheckPath != "" {
+		r.GET(i.Config.Server.HealthCheckPath, handlers.HealthHandler).Silent()
+	}
+
+	r.GET(
+		"/*", i.ProcessingHandler.Execute,
+		r.WithSecret, r.WithCORS, r.WithPanic, r.WithReportError, r.WithMonitoring,
+	)
+
+	r.HEAD("/*", r.OkHandler, r.WithCORS)
+	r.OPTIONS("/*", r.OkHandler, r.WithCORS)
+
+	return r, nil
+}
+
+// Start runs the imgproxy server. This function blocks until the context is cancelled.
+func (i *Imgproxy) StartServer(ctx context.Context) error {
+	go i.startMemoryTicker(ctx)
+
+	ctx, cancel := context.WithCancel(ctx)
+
+	if err := prometheus.StartServer(cancel); err != nil {
+		return err
+	}
+
+	router, err := i.BuildRouter()
+	if err != nil {
+		return err
+	}
+
+	s, err := server.Start(cancel, router)
+	if err != nil {
+		return err
+	}
+	defer s.Shutdown(context.Background())
+
+	<-ctx.Done()
+
+	return nil
+}
+
+// startMemoryTicker starts a ticker that periodically frees memory and optionally logs memory stats
+func (i *Imgproxy) startMemoryTicker(ctx context.Context) {
+	ticker := time.NewTicker(i.Config.Server.FreeMemoryInterval)
+	defer ticker.Stop()
+
+	for {
+		select {
+		case <-ctx.Done():
+			return
+		case <-ticker.C:
+			memory.Free()
+
+			if i.Config.Server.LogMemStats {
+				memory.LogStats()
+			}
+		}
+	}
+}

+ 74 - 0
init.go

@@ -0,0 +1,74 @@
+// init_once.go contains global initialization/teardown functions that should be called exactly once
+// per process.
+package imgproxy
+
+import (
+	"github.com/DataDog/datadog-agent/pkg/trace/log"
+	"github.com/imgproxy/imgproxy/v3/config"
+	"github.com/imgproxy/imgproxy/v3/config/loadenv"
+	"github.com/imgproxy/imgproxy/v3/errorreport"
+	"github.com/imgproxy/imgproxy/v3/gliblog"
+	"github.com/imgproxy/imgproxy/v3/logger"
+	"github.com/imgproxy/imgproxy/v3/monitoring"
+	"github.com/imgproxy/imgproxy/v3/options"
+	"github.com/imgproxy/imgproxy/v3/processing"
+	"github.com/imgproxy/imgproxy/v3/vips"
+	"go.uber.org/automaxprocs/maxprocs"
+)
+
+// Init performs the global resources initialization. This should be done once per process.
+func Init() error {
+	if err := loadenv.Load(); err != nil {
+		return err
+	}
+
+	if err := logger.Init(); err != nil {
+		return err
+	}
+
+	// NOTE: This is temporary workaround. We have to load env vars in config.go before
+	// actually configuring ImgProxy instance because for now we use it as a source of truth.
+	// Will be removed once we move env var loading to imgproxy.go
+	if err := config.Configure(); err != nil {
+		return err
+	}
+	// NOTE: End of temporary workaround.
+
+	gliblog.Init()
+
+	maxprocs.Set(maxprocs.Logger(log.Debugf))
+
+	if err := monitoring.Init(); err != nil {
+		return err
+	}
+
+	if err := vips.Init(); err != nil {
+		return err
+	}
+
+	errorreport.Init()
+
+	if err := processing.ValidatePreferredFormats(); err != nil {
+		vips.Shutdown()
+		return err
+	}
+
+	if err := options.ParsePresets(config.Presets); err != nil {
+		vips.Shutdown()
+		return err
+	}
+
+	if err := options.ValidatePresets(); err != nil {
+		vips.Shutdown()
+		return err
+	}
+
+	return nil
+}
+
+// Shutdown performs global cleanup
+func Shutdown() {
+	monitoring.Stop()
+	errorreport.Close()
+	vips.Shutdown()
+}

+ 103 - 60
integration/load_test.go

@@ -1,12 +1,12 @@
-//go:build integration
-// +build integration
-
 package integration
 
 import (
 	"bytes"
+	"context"
 	"fmt"
 	"image/png"
+	"io"
+	"net/http"
 	"os"
 	"path"
 	"path/filepath"
@@ -14,28 +14,50 @@ import (
 	"testing"
 
 	"github.com/corona10/goimagehash"
+	"github.com/imgproxy/imgproxy/v3"
+	"github.com/imgproxy/imgproxy/v3/config"
 	"github.com/imgproxy/imgproxy/v3/imagetype"
+	"github.com/imgproxy/imgproxy/v3/testutil"
 	"github.com/imgproxy/imgproxy/v3/vips"
-	"github.com/stretchr/testify/assert"
-	"github.com/stretchr/testify/require"
+	"github.com/stretchr/testify/suite"
 )
 
 const (
 	similarityThreshold = 5 // Distance between images to be considered similar
 )
 
+type LoadTestSuite struct {
+	suite.Suite
+	ctx            context.Context
+	cancel         context.CancelFunc
+	testData       *testutil.TestDataProvider
+	testImagesPath string
+}
+
+// SetupSuite starts imgproxy instance server
+func (s *LoadTestSuite) SetupSuite() {
+	s.testData = testutil.NewTestDataProvider(s.T())
+	s.testImagesPath = s.testData.Path("test-images")
+	s.ctx, s.cancel = context.WithCancel(s.T().Context())
+
+	s.startImgproxy(s.ctx)
+}
+
+// TearDownSuite stops imgproxy instance server
+func (s *LoadTestSuite) TearDownSuite() {
+	s.cancel()
+}
+
 // testLoadFolder fetches images iterates over images in the specified folder,
 // runs imgproxy on each image, and compares the result with the reference image
 // which is expected to be in the `integration` folder with the same name
 // but with `.png` extension.
-func testLoadFolder(t *testing.T, cs, sourcePath, folder string) {
-	t.Logf("Testing folder: %s", folder)
-
-	walkPath := path.Join(sourcePath, folder)
+func (s *LoadTestSuite) testLoadFolder(folder string) {
+	walkPath := path.Join(s.testImagesPath, folder)
 
 	// Iterate over the files in the source folder
 	err := filepath.Walk(walkPath, func(path string, info os.FileInfo, err error) error {
-		require.NoError(t, err)
+		s.Require().NoError(err)
 
 		// Skip directories
 		if info.IsDir() {
@@ -49,93 +71,114 @@ func testLoadFolder(t *testing.T, cs, sourcePath, folder string) {
 		referencePath := strings.TrimSuffix(basePath, filepath.Ext(basePath)) + ".png"
 
 		// Construct the full path to the reference image (integration/ folder)
-		referencePath = filepath.Join(sourcePath, "integration", folder, referencePath)
+		referencePath = filepath.Join(s.testImagesPath, "integration", folder, referencePath)
 
 		// Construct the source URL for imgproxy (no processing)
 		sourceUrl := fmt.Sprintf("insecure/plain/local:///%s/%s@png", folder, basePath)
 
-		imgproxyImageBytes := fetchImage(t, cs, sourceUrl)
+		imgproxyImageBytes := s.fetchImage(sourceUrl)
 		imgproxyImage, err := png.Decode(bytes.NewReader(imgproxyImageBytes))
-		require.NoError(t, err, "Failed to decode PNG image from imgproxy for %s", basePath)
+		s.Require().NoError(err, "Failed to decode PNG image from imgproxy for %s", basePath)
 
 		referenceFile, err := os.Open(referencePath)
-		require.NoError(t, err)
+		s.Require().NoError(err)
 		defer referenceFile.Close()
 
 		referenceImage, err := png.Decode(referenceFile)
-		require.NoError(t, err, "Failed to decode PNG reference image for %s", referencePath)
+		s.Require().NoError(err, "Failed to decode PNG reference image for %s", referencePath)
 
 		hash1, err := goimagehash.DifferenceHash(imgproxyImage)
-		require.NoError(t, err)
+		s.Require().NoError(err)
 
 		hash2, err := goimagehash.DifferenceHash(referenceImage)
-		require.NoError(t, err)
+		s.Require().NoError(err)
 
 		distance, err := hash1.Distance(hash2)
-		require.NoError(t, err)
+		s.Require().NoError(err)
 
-		assert.LessOrEqual(t, distance, similarityThreshold,
+		s.Require().LessOrEqual(distance, similarityThreshold,
 			"Image %s differs from reference image %s by %d, which is greater than the allowed threshold of %d",
 			basePath, referencePath, distance, similarityThreshold)
 
 		return nil
 	})
 
-	require.NoError(t, err)
+	s.Require().NoError(err)
 }
 
-// TestLoadSaveToPng ensures that our load pipeline works,
-// including standard and custom loaders. For each source image
-// in the folder, it does the passthrough request through imgproxy:
-// no processing, just convert format of the source file to png.
-// Then, it compares the result with the reference image.
-func TestLoadSaveToPng(t *testing.T) {
-	ctx := t.Context()
+// fetchImage fetches an image from the imgproxy server
+func (s *LoadTestSuite) fetchImage(path string) []byte {
+	url := fmt.Sprintf("http://%s:%d/%s", bindHost, bindPort, path)
 
-	// TODO: Will be moved to test suite (like in processing_test.go)
-	// Since we use SupportsLoad, we need to initialize vips
-	defer vips.Shutdown() // either way it needs to be deinitialized
-	err := vips.Init()
-	require.NoError(t, err, "Failed to initialize vips")
+	resp, err := http.Get(url)
+	s.Require().NoError(err, "Failed to fetch image from %s", url)
+	defer resp.Body.Close()
 
-	path, err := testImagesPath(t)
-	require.NoError(t, err)
+	s.Require().Equal(http.StatusOK, resp.StatusCode, "Expected status code 200 OK, got %d, url: %s", resp.StatusCode, url)
 
-	cs := startImgproxy(t, ctx, path)
+	bytes, err := io.ReadAll(resp.Body)
+	s.Require().NoError(err, "Failed to read response body from %s", url)
 
-	if vips.SupportsLoad(imagetype.GIF) {
-		testLoadFolder(t, cs, path, "gif")
-	}
+	return bytes
+}
 
-	if vips.SupportsLoad(imagetype.JPEG) {
-		testLoadFolder(t, cs, path, "jpg")
-	}
+func (s *LoadTestSuite) startImgproxy(ctx context.Context) *imgproxy.Imgproxy {
+	c, err := imgproxy.LoadConfigFromEnv(nil)
+	s.Require().NoError(err)
 
-	if vips.SupportsLoad(imagetype.HEIC) {
-		testLoadFolder(t, cs, path, "heif")
-	}
+	c.Server.Bind = ":" + fmt.Sprintf("%d", bindPort)
+	c.Transport.Local.Root = s.testImagesPath
+	c.Server.LogMemStats = true
 
-	if vips.SupportsLoad(imagetype.JXL) {
-		testLoadFolder(t, cs, path, "jxl")
-	}
+	config.MaxAnimationFrames = 999
+	config.DevelopmentErrorsMode = true
 
-	if vips.SupportsLoad(imagetype.SVG) {
-		testLoadFolder(t, cs, path, "svg")
-	}
+	i, err := imgproxy.New(ctx, c)
+	s.Require().NoError(err)
 
-	if vips.SupportsLoad(imagetype.TIFF) {
-		testLoadFolder(t, cs, path, "tiff")
-	}
+	go func() {
+		err = i.StartServer(ctx)
+		if err != nil {
+			s.T().Errorf("Imgproxy server exited with error: %v", err)
+		}
+	}()
 
-	if vips.SupportsLoad(imagetype.WEBP) {
-		testLoadFolder(t, cs, path, "webp")
-	}
+	return i
+}
 
-	if vips.SupportsLoad(imagetype.BMP) {
-		testLoadFolder(t, cs, path, "bmp")
+// TestLoadSaveToPng ensures that our load pipeline works,
+// including standard and custom loaders. For each source image
+// in the folder, it does the passthrough request through imgproxy:
+// no processing, just convert format of the source file to png.
+// Then, it compares the result with the reference image.
+func (s *LoadTestSuite) TestLoadSaveToPng() {
+	testCases := []struct {
+		name       string
+		imageType  imagetype.Type
+		folderName string
+	}{
+		{"GIF", imagetype.GIF, "gif"},
+		{"JPEG", imagetype.JPEG, "jpg"},
+		{"HEIC", imagetype.HEIC, "heif"},
+		{"JXL", imagetype.JXL, "jxl"},
+		{"SVG", imagetype.SVG, "svg"},
+		{"TIFF", imagetype.TIFF, "tiff"},
+		{"WEBP", imagetype.WEBP, "webp"},
+		{"BMP", imagetype.BMP, "bmp"},
+		{"ICO", imagetype.ICO, "ico"},
 	}
 
-	if vips.SupportsLoad(imagetype.ICO) {
-		testLoadFolder(t, cs, path, "ico")
+	for _, tc := range testCases {
+		s.T().Run(tc.name, func(t *testing.T) {
+			if vips.SupportsLoad(tc.imageType) {
+				s.testLoadFolder(tc.folderName)
+			} else {
+				t.Skipf("%s format not supported by VIPS", tc.name)
+			}
+		})
 	}
 }
+
+func TestIntegration(t *testing.T) {
+	suite.Run(t, new(LoadTestSuite))
+}

+ 20 - 0
integration/main_test.go

@@ -0,0 +1,20 @@
+package integration
+
+import (
+	"os"
+	"testing"
+
+	"github.com/imgproxy/imgproxy/v3"
+)
+
+const (
+	bindPort = 9090 // Port to bind imgproxy to
+	bindHost = "localhost"
+)
+
+// TestMain performs global setup/teardown for the integration tests.
+func TestMain(m *testing.M) {
+	imgproxy.Init()
+	os.Exit(m.Run())
+	imgproxy.Shutdown()
+}

+ 181 - 253
processing_handler_test.go → integration/processing_handler_test.go

@@ -1,9 +1,4 @@
-package main
-
-// NOTE: this test is the integration test for the processing handler. We can't extract and
-// move it to handlers package yet because it depends on the global routes, methods and
-// initialization functions. Once those would we wrapped into structures, we'll be able to move this test
-// to where it belongs.
+package integration
 
 import (
 	"fmt"
@@ -11,155 +6,128 @@ import (
 	"net/http"
 	"net/http/httptest"
 	"os"
-	"path/filepath"
 	"regexp"
 	"testing"
 	"time"
 
-	"github.com/sirupsen/logrus"
-	"github.com/stretchr/testify/suite"
-
+	"github.com/imgproxy/imgproxy/v3"
 	"github.com/imgproxy/imgproxy/v3/config"
 	"github.com/imgproxy/imgproxy/v3/config/configurators"
-	"github.com/imgproxy/imgproxy/v3/fetcher"
 	"github.com/imgproxy/imgproxy/v3/httpheaders"
 	"github.com/imgproxy/imgproxy/v3/imagedata"
 	"github.com/imgproxy/imgproxy/v3/imagetype"
 	"github.com/imgproxy/imgproxy/v3/server"
 	"github.com/imgproxy/imgproxy/v3/svg"
 	"github.com/imgproxy/imgproxy/v3/testutil"
-	"github.com/imgproxy/imgproxy/v3/transport"
 	"github.com/imgproxy/imgproxy/v3/vips"
+	"github.com/sirupsen/logrus"
+	"github.com/stretchr/testify/suite"
 )
 
+// ProcessingHandlerTestSuite is a test suite for testing image processing handler
 type ProcessingHandlerTestSuite struct {
 	suite.Suite
 
-	router *server.Router
+	testData *testutil.TestDataProvider
+	config   testutil.LazyObj[*imgproxy.Config]
+	router   testutil.LazyObj[*server.Router]
+	imgproxy testutil.LazyObj[*imgproxy.Imgproxy]
 }
 
 func (s *ProcessingHandlerTestSuite) SetupSuite() {
-	config.Reset()
-
-	wd, err := os.Getwd()
-	s.Require().NoError(err)
-
-	s.T().Setenv("IMGPROXY_LOCAL_FILESYSTEM_ROOT", filepath.Join(wd, "/testdata"))
-	s.T().Setenv("IMGPROXY_CLIENT_KEEP_ALIVE_TIMEOUT", "0")
-
-	err = initialize()
-	s.Require().NoError(err)
-
+	// Silence all the logs
 	logrus.SetOutput(io.Discard)
 
-	cfg := server.NewDefaultConfig()
-	r, err := server.NewRouter(&cfg)
-	s.Require().NoError(err)
-
-	s.router = buildRouter(r)
+	// Initialize test data provider (local test files)
+	s.testData = testutil.NewTestDataProvider(s.T())
 }
 
 func (s *ProcessingHandlerTestSuite) TeardownSuite() {
-	shutdown()
 	logrus.SetOutput(os.Stdout)
 }
 
-func (s *ProcessingHandlerTestSuite) SetupTest() {
-	wd, err := os.Getwd()
-	s.Require().NoError(err)
-
-	config.Reset()
-	config.AllowLoopbackSourceAddresses = true
-	config.LocalFileSystemRoot = filepath.Join(wd, "/testdata")
-	config.ClientKeepAliveTimeout = 0
-}
+// setupObjs initializes lazy objects
+func (s *ProcessingHandlerTestSuite) setupObjs() {
+	s.config = testutil.NewLazyObj(s.T(), func() (*imgproxy.Config, error) {
+		c, err := imgproxy.LoadConfigFromEnv(nil)
+		s.Require().NoError(err)
 
-func (s *ProcessingHandlerTestSuite) send(path string, header ...http.Header) *httptest.ResponseRecorder {
-	req := httptest.NewRequest(http.MethodGet, path, nil)
-	rw := httptest.NewRecorder()
+		c.Transport.Local.Root = s.testData.Root()
+		c.Transport.HTTP.ClientKeepAliveTimeout = 0
 
-	if len(header) > 0 {
-		req.Header = header[0]
-	}
+		return c, nil
+	})
 
-	s.router.ServeHTTP(rw, req)
+	s.imgproxy = testutil.NewLazyObj(s.T(), func() (*imgproxy.Imgproxy, error) {
+		return imgproxy.New(s.T().Context(), s.config())
+	})
 
-	return rw
+	s.router = testutil.NewLazyObj(s.T(), func() (*server.Router, error) {
+		return s.imgproxy().BuildRouter()
+	})
 }
 
-func (s *ProcessingHandlerTestSuite) readTestFile(name string) []byte {
-	wd, err := os.Getwd()
-	s.Require().NoError(err)
+func (s *ProcessingHandlerTestSuite) SetupTest() {
+	config.Reset() // We reset config only at the start of each test
 
-	data, err := os.ReadFile(filepath.Join(wd, "testdata", name))
-	s.Require().NoError(err)
+	// NOTE: This must be moved to security config
+	config.AllowLoopbackSourceAddresses = true
+	// NOTE: end note
 
-	return data
+	s.setupObjs()
 }
 
-func (s *ProcessingHandlerTestSuite) readTestImageData(name string) imagedata.ImageData {
-	wd, err := os.Getwd()
-	s.Require().NoError(err)
-
-	data, err := os.ReadFile(filepath.Join(wd, "testdata", name))
-	s.Require().NoError(err)
-
-	// NOTE: Temporary workaround. We create imagedata.Factory here
-	// because currently configuration is changed via env vars
-	// or config. We need to pick up those config changes.
-	// This will be addressed in the next PR
-	trc, err := transport.LoadConfigFromEnv(nil)
-	s.Require().NoError(err)
-
-	tr, err := transport.New(trc)
-	s.Require().NoError(err)
-
-	fc, err := fetcher.LoadConfigFromEnv(nil)
-	s.Require().NoError(err)
-
-	f, err := fetcher.New(tr, fc)
-	s.Require().NoError(err)
-
-	idf := imagedata.NewFactory(f)
-	// end of workaround
-
-	imgdata, err := idf.NewFromBytes(data)
-	s.Require().NoError(err)
-
-	return imgdata
+func (s *ProcessingHandlerTestSuite) SetupSubTest() {
+	// We use t.Run() a lot, so we need to reset lazy objects at the beginning of each subtest
+	s.setupObjs()
 }
 
-func (s *ProcessingHandlerTestSuite) TestRequest() {
-	rw := s.send("/unsafe/rs:fill:4:4/plain/local:///test1.png")
-	res := rw.Result()
+// GET performs a GET request to the given path and returns the response recorder
+func (s *ProcessingHandlerTestSuite) GET(path string, header ...http.Header) *http.Response {
+	req := httptest.NewRequest(http.MethodGet, path, nil)
+	rw := httptest.NewRecorder()
 
-	s.Require().Equal(200, res.StatusCode)
-	s.Require().Equal("image/png", res.Header.Get("Content-Type"))
+	if len(header) > 0 {
+		req.Header = header[0]
+	}
 
-	format, err := imagetype.Detect(res.Body)
+	s.router().ServeHTTP(rw, req)
 
-	s.Require().NoError(err)
-	s.Require().Equal(imagetype.PNG, format)
+	return rw.Result()
 }
 
 func (s *ProcessingHandlerTestSuite) TestSignatureValidationFailure() {
 	config.Keys = [][]byte{[]byte("test-key")}
 	config.Salts = [][]byte{[]byte("test-salt")}
 
-	rw := s.send("/unsafe/rs:fill:4:4/plain/local:///test1.png")
-	res := rw.Result()
-
-	s.Require().Equal(403, res.StatusCode)
-}
-
-func (s *ProcessingHandlerTestSuite) TestSignatureValidationSuccess() {
-	config.Keys = [][]byte{[]byte("test-key")}
-	config.Salts = [][]byte{[]byte("test-salt")}
-
-	rw := s.send("/My9d3xq_PYpVHsPrCyww0Kh1w5KZeZhIlWhsa4az1TI/rs:fill:4:4/plain/local:///test1.png")
-	res := rw.Result()
+	tt := []struct {
+		name       string
+		url        string
+		statusCode int
+	}{
+		{
+			name:       "NoSignature",
+			url:        "/unsafe/rs:fill:4:4/plain/local:///test1.png",
+			statusCode: http.StatusForbidden,
+		},
+		{
+			name:       "BadSignature",
+			url:        "/bad-signature/rs:fill:4:4/plain/local:///test1.png",
+			statusCode: http.StatusForbidden,
+		},
+		{
+			name:       "ValidSignature",
+			url:        "/My9d3xq_PYpVHsPrCyww0Kh1w5KZeZhIlWhsa4az1TI/rs:fill:4:4/plain/local:///test1.png",
+			statusCode: http.StatusOK,
+		},
+	}
 
-	s.Require().Equal(200, res.StatusCode)
+	for _, tc := range tt {
+		s.Run(tc.name, func() {
+			res := s.GET(tc.url)
+			s.Require().Equal(tc.statusCode, res.StatusCode)
+		})
+	}
 }
 
 func (s *ProcessingHandlerTestSuite) TestSourceValidation() {
@@ -176,19 +144,16 @@ func (s *ProcessingHandlerTestSuite) TestSourceValidation() {
 			name:           "match http URL without wildcard",
 			allowedSources: []string{"local://", "http://images.dev/"},
 			requestPath:    "/unsafe/plain/http://images.dev/lorem/ipsum.jpg",
-			expectedError:  false,
 		},
 		{
 			name:           "match http URL with wildcard in hostname single level",
 			allowedSources: []string{"local://", "http://*.mycdn.dev/"},
 			requestPath:    "/unsafe/plain/http://a-1.mycdn.dev/lorem/ipsum.jpg",
-			expectedError:  false,
 		},
 		{
 			name:           "match http URL with wildcard in hostname multiple levels",
 			allowedSources: []string{"local://", "http://*.mycdn.dev/"},
 			requestPath:    "/unsafe/plain/http://a-1.b-2.mycdn.dev/lorem/ipsum.jpg",
-			expectedError:  false,
 		},
 		{
 			name:           "no match s3 URL with allowed local and http URLs",
@@ -206,26 +171,24 @@ func (s *ProcessingHandlerTestSuite) TestSourceValidation() {
 
 	for _, tc := range tt {
 		s.Run(tc.name, func() {
-			exps := make([]*regexp.Regexp, len(tc.allowedSources))
+			config.AllowedSources = make([]*regexp.Regexp, len(tc.allowedSources))
 			for i, pattern := range tc.allowedSources {
-				exps[i] = configurators.RegexpFromPattern(pattern)
+				config.AllowedSources[i] = configurators.RegexpFromPattern(pattern)
 			}
-			config.AllowedSources = exps
 
-			rw := s.send(tc.requestPath)
-			res := rw.Result()
+			res := s.GET(tc.requestPath)
 
 			if tc.expectedError {
-				s.Require().Equal(404, res.StatusCode)
+				s.Require().Equal(http.StatusNotFound, res.StatusCode)
 			} else {
-				s.Require().Equal(200, res.StatusCode)
+				s.Require().Equal(http.StatusOK, res.StatusCode)
 			}
 		})
 	}
 }
 
 func (s *ProcessingHandlerTestSuite) TestSourceNetworkValidation() {
-	data := s.readTestFile("test1.png")
+	data := s.testData.Read("test1.png")
 
 	server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
 		rw.WriteHeader(200)
@@ -233,180 +196,158 @@ func (s *ProcessingHandlerTestSuite) TestSourceNetworkValidation() {
 	}))
 	defer server.Close()
 
-	var rw *httptest.ResponseRecorder
-
-	u := fmt.Sprintf("/unsafe/rs:fill:4:4/plain/%s/test1.png", server.URL)
+	url := fmt.Sprintf("/unsafe/rs:fill:4:4/plain/%s/test1.png", server.URL)
 
-	rw = s.send(u)
-	s.Require().Equal(200, rw.Result().StatusCode)
+	// We wrap this in a subtest to reset s.router()
+	s.Run("AllowLoopbackSourceAddressesTrue", func() {
+		config.AllowLoopbackSourceAddresses = true
+		res := s.GET(url)
+		s.Require().Equal(http.StatusOK, res.StatusCode)
+	})
 
-	config.AllowLoopbackSourceAddresses = false
-	rw = s.send(u)
-	s.Require().Equal(404, rw.Result().StatusCode)
+	s.Run("AllowLoopbackSourceAddressesFalse", func() {
+		config.AllowLoopbackSourceAddresses = false
+		res := s.GET(url)
+		s.Require().Equal(http.StatusNotFound, res.StatusCode)
+	})
 }
 
 func (s *ProcessingHandlerTestSuite) TestSourceFormatNotSupported() {
 	vips.DisableLoadSupport(imagetype.PNG)
 	defer vips.ResetLoadSupport()
 
-	rw := s.send("/unsafe/rs:fill:4:4/plain/local:///test1.png")
-	res := rw.Result()
-
-	s.Require().Equal(422, res.StatusCode)
+	res := s.GET("/unsafe/rs:fill:4:4/plain/local:///test1.png")
+	s.Require().Equal(http.StatusUnprocessableEntity, res.StatusCode)
 }
 
 func (s *ProcessingHandlerTestSuite) TestResultingFormatNotSupported() {
 	vips.DisableSaveSupport(imagetype.PNG)
 	defer vips.ResetSaveSupport()
 
-	rw := s.send("/unsafe/rs:fill:4:4/plain/local:///test1.png@png")
-	res := rw.Result()
-
-	s.Require().Equal(422, res.StatusCode)
+	res := s.GET("/unsafe/rs:fill:4:4/plain/local:///test1.png@png")
+	s.Require().Equal(http.StatusUnprocessableEntity, res.StatusCode)
 }
 
 func (s *ProcessingHandlerTestSuite) TestSkipProcessingConfig() {
 	config.SkipProcessingFormats = []imagetype.Type{imagetype.PNG}
 
-	rw := s.send("/unsafe/rs:fill:4:4/plain/local:///test1.png")
-	res := rw.Result()
+	res := s.GET("/unsafe/rs:fill:4:4/plain/local:///test1.png")
 
-	s.Require().Equal(200, res.StatusCode)
-
-	expected := s.readTestImageData("test1.png")
-
-	s.Require().True(testutil.ReadersEqual(s.T(), expected.Reader(), res.Body))
+	s.Require().Equal(http.StatusOK, res.StatusCode)
+	s.Require().True(s.testData.FileEqualsToReader("test1.png", res.Body))
 }
 
 func (s *ProcessingHandlerTestSuite) TestSkipProcessingPO() {
-	rw := s.send("/unsafe/rs:fill:4:4/skp:png/plain/local:///test1.png")
-	res := rw.Result()
-
-	s.Require().Equal(200, res.StatusCode)
-
-	expected := s.readTestImageData("test1.png")
+	res := s.GET("/unsafe/rs:fill:4:4/skp:png/plain/local:///test1.png")
 
-	s.Require().True(testutil.ReadersEqual(s.T(), expected.Reader(), res.Body))
+	s.Require().Equal(http.StatusOK, res.StatusCode)
+	s.Require().True(s.testData.FileEqualsToReader("test1.png", res.Body))
 }
 
 func (s *ProcessingHandlerTestSuite) TestSkipProcessingSameFormat() {
 	config.SkipProcessingFormats = []imagetype.Type{imagetype.PNG}
 
-	rw := s.send("/unsafe/rs:fill:4:4/plain/local:///test1.png@png")
-	res := rw.Result()
-
-	s.Require().Equal(200, res.StatusCode)
-
-	expected := s.readTestImageData("test1.png")
+	res := s.GET("/unsafe/rs:fill:4:4/plain/local:///test1.png@png")
 
-	s.Require().True(testutil.ReadersEqual(s.T(), expected.Reader(), res.Body))
+	s.Require().Equal(http.StatusOK, res.StatusCode)
+	s.Require().True(s.testData.FileEqualsToReader("test1.png", res.Body))
 }
 
 func (s *ProcessingHandlerTestSuite) TestSkipProcessingDifferentFormat() {
 	config.SkipProcessingFormats = []imagetype.Type{imagetype.PNG}
 
-	rw := s.send("/unsafe/rs:fill:4:4/plain/local:///test1.png@jpg")
-	res := rw.Result()
+	res := s.GET("/unsafe/rs:fill:4:4/plain/local:///test1.png@jpg")
 
-	s.Require().Equal(200, res.StatusCode)
-
-	expected := s.readTestImageData("test1.png")
-
-	s.Require().False(testutil.ReadersEqual(s.T(), expected.Reader(), res.Body))
+	s.Require().Equal(http.StatusOK, res.StatusCode)
+	s.Require().False(s.testData.FileEqualsToReader("test1.png", res.Body))
 }
 
 func (s *ProcessingHandlerTestSuite) TestSkipProcessingSVG() {
-	rw := s.send("/unsafe/rs:fill:4:4/plain/local:///test1.svg")
-	res := rw.Result()
+	res := s.GET("/unsafe/rs:fill:4:4/plain/local:///test1.svg")
 
-	s.Require().Equal(200, res.StatusCode)
+	s.Require().Equal(http.StatusOK, res.StatusCode)
 
-	expected, err := svg.Sanitize(s.readTestImageData("test1.svg"))
+	data, err := s.imgproxy().ImageDataFactory.NewFromBytes(s.testData.Read("test1.svg"))
+	s.Require().NoError(err)
+
+	expected, err := svg.Sanitize(data)
 	s.Require().NoError(err)
 
 	s.Require().True(testutil.ReadersEqual(s.T(), expected.Reader(), res.Body))
 }
 
 func (s *ProcessingHandlerTestSuite) TestNotSkipProcessingSVGToJPG() {
-	rw := s.send("/unsafe/rs:fill:4:4/plain/local:///test1.svg@jpg")
-	res := rw.Result()
-
-	s.Require().Equal(200, res.StatusCode)
-
-	expected := s.readTestImageData("test1.svg")
+	res := s.GET("/unsafe/rs:fill:4:4/plain/local:///test1.svg@jpg")
 
-	s.Require().False(testutil.ReadersEqual(s.T(), expected.Reader(), res.Body))
+	s.Require().Equal(http.StatusOK, res.StatusCode)
+	s.Require().False(s.testData.FileEqualsToReader("test1.svg", res.Body))
 }
 
 func (s *ProcessingHandlerTestSuite) TestErrorSavingToSVG() {
-	rw := s.send("/unsafe/rs:fill:4:4/plain/local:///test1.png@svg")
-	res := rw.Result()
+	res := s.GET("/unsafe/rs:fill:4:4/plain/local:///test1.png@svg")
 
-	s.Require().Equal(422, res.StatusCode)
+	s.Require().Equal(http.StatusUnprocessableEntity, res.StatusCode)
 }
 
 func (s *ProcessingHandlerTestSuite) TestCacheControlPassthroughCacheControl() {
-	config.CacheControlPassthrough = true
+	s.config().HeaderWriter.CacheControlPassthrough = true
 
 	ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
-		rw.Header().Set("Cache-Control", "max-age=1234, public")
-		rw.Header().Set("Expires", time.Now().Add(time.Hour).UTC().Format(http.TimeFormat))
+		rw.Header().Set(httpheaders.CacheControl, "max-age=1234, public")
+		rw.Header().Set(httpheaders.Expires, time.Now().Add(time.Hour).UTC().Format(http.TimeFormat))
 		rw.WriteHeader(200)
-		rw.Write(s.readTestFile("test1.png"))
+		rw.Write(s.testData.Read("test1.png"))
 	}))
 	defer ts.Close()
 
-	rw := s.send("/unsafe/rs:fill:4:4/plain/" + ts.URL)
-	res := rw.Result()
+	res := s.GET("/unsafe/rs:fill:4:4/plain/" + ts.URL)
 
-	s.Require().Equal("max-age=1234, public", res.Header.Get("Cache-Control"))
-	s.Require().Empty(res.Header.Get("Expires"))
+	s.Require().Equal(http.StatusOK, res.StatusCode)
+	s.Require().Equal("max-age=1234, public", res.Header.Get(httpheaders.CacheControl))
+	s.Require().Empty(res.Header.Get(httpheaders.Expires))
 }
 
 func (s *ProcessingHandlerTestSuite) TestCacheControlPassthroughExpires() {
 	config.CacheControlPassthrough = true
 
 	ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
-		rw.Header().Set("Expires", time.Now().Add(1239*time.Second).UTC().Format(http.TimeFormat))
+		rw.Header().Set(httpheaders.Expires, time.Now().Add(1239*time.Second).UTC().Format(http.TimeFormat))
 		rw.WriteHeader(200)
-		rw.Write(s.readTestFile("test1.png"))
+		rw.Write(s.testData.Read("test1.png"))
 	}))
 	defer ts.Close()
 
-	rw := s.send("/unsafe/rs:fill:4:4/plain/" + ts.URL)
-	res := rw.Result()
+	res := s.GET("/unsafe/rs:fill:4:4/plain/" + ts.URL)
 
 	// Use regex to allow some delay
-	s.Require().Regexp("max-age=123[0-9], public", res.Header.Get("Cache-Control"))
-	s.Require().Empty(res.Header.Get("Expires"))
+	s.Require().Regexp("max-age=123[0-9], public", res.Header.Get(httpheaders.CacheControl))
+	s.Require().Empty(res.Header.Get(httpheaders.Expires))
 }
 
 func (s *ProcessingHandlerTestSuite) TestCacheControlPassthroughDisabled() {
 	config.CacheControlPassthrough = false
 
 	ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
-		rw.Header().Set("Cache-Control", "max-age=1234, public")
-		rw.Header().Set("Expires", time.Now().Add(time.Hour).UTC().Format(http.TimeFormat))
+		rw.Header().Set(httpheaders.CacheControl, "max-age=1234, public")
+		rw.Header().Set(httpheaders.Expires, time.Now().Add(time.Hour).UTC().Format(http.TimeFormat))
 		rw.WriteHeader(200)
-		rw.Write(s.readTestFile("test1.png"))
+		rw.Write(s.testData.Read("test1.png"))
 	}))
 	defer ts.Close()
 
-	rw := s.send("/unsafe/rs:fill:4:4/plain/" + ts.URL)
-	res := rw.Result()
+	res := s.GET("/unsafe/rs:fill:4:4/plain/" + ts.URL)
 
-	s.Require().NotEqual("max-age=1234, public", res.Header.Get("Cache-Control"))
-	s.Require().Empty(res.Header.Get("Expires"))
+	s.Require().NotEqual("max-age=1234, public", res.Header.Get(httpheaders.CacheControl))
+	s.Require().Empty(res.Header.Get(httpheaders.Expires))
 }
 
 func (s *ProcessingHandlerTestSuite) TestETagDisabled() {
 	config.ETagEnabled = false
 
-	rw := s.send("/unsafe/rs:fill:4:4/plain/local:///test1.png")
-	res := rw.Result()
+	res := s.GET("/unsafe/rs:fill:4:4/plain/local:///test1.png")
 
 	s.Require().Equal(200, res.StatusCode)
-	s.Require().Empty(res.Header.Get("ETag"))
+	s.Require().Empty(res.Header.Get(httpheaders.Etag))
 }
 
 func (s *ProcessingHandlerTestSuite) TestETagDataMatch() {
@@ -425,8 +366,7 @@ func (s *ProcessingHandlerTestSuite) TestETagDataMatch() {
 	header := make(http.Header)
 	header.Set(httpheaders.IfNoneMatch, etag)
 
-	rw := s.send(fmt.Sprintf("/unsafe/plain/%s", ts.URL), header)
-	res := rw.Result()
+	res := s.GET(fmt.Sprintf("/unsafe/plain/%s", ts.URL), header)
 
 	s.Require().Equal(304, res.StatusCode)
 	s.Require().Equal(etag, res.Header.Get(httpheaders.Etag))
@@ -435,39 +375,37 @@ func (s *ProcessingHandlerTestSuite) TestETagDataMatch() {
 func (s *ProcessingHandlerTestSuite) TestLastModifiedEnabled() {
 	config.LastModifiedEnabled = true
 	ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
-		rw.Header().Set("Last-Modified", "Wed, 21 Oct 2015 07:28:00 GMT")
+		rw.Header().Set(httpheaders.LastModified, "Wed, 21 Oct 2015 07:28:00 GMT")
 		rw.WriteHeader(200)
-		rw.Write(s.readTestFile("test1.png"))
+		rw.Write(s.testData.Read("test1.png"))
 	}))
 	defer ts.Close()
 
-	rw := s.send("/unsafe/rs:fill:4:4/plain/" + ts.URL)
-	res := rw.Result()
+	res := s.GET("/unsafe/rs:fill:4:4/plain/" + ts.URL)
 
-	s.Require().Equal("Wed, 21 Oct 2015 07:28:00 GMT", res.Header.Get("Last-Modified"))
+	s.Require().Equal("Wed, 21 Oct 2015 07:28:00 GMT", res.Header.Get(httpheaders.LastModified))
 }
 
 func (s *ProcessingHandlerTestSuite) TestLastModifiedDisabled() {
 	config.LastModifiedEnabled = false
 	ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
-		rw.Header().Set("Last-Modified", "Wed, 21 Oct 2015 07:28:00 GMT")
+		rw.Header().Set(httpheaders.LastModified, "Wed, 21 Oct 2015 07:28:00 GMT")
 		rw.WriteHeader(200)
-		rw.Write(s.readTestFile("test1.png"))
+		rw.Write(s.testData.Read("test1.png"))
 	}))
 	defer ts.Close()
 
-	rw := s.send("/unsafe/rs:fill:4:4/plain/" + ts.URL)
-	res := rw.Result()
+	res := s.GET("/unsafe/rs:fill:4:4/plain/" + ts.URL)
 
-	s.Require().Empty(res.Header.Get("Last-Modified"))
+	s.Require().Empty(res.Header.Get(httpheaders.LastModified))
 }
 
 func (s *ProcessingHandlerTestSuite) TestModifiedSinceReqExactMatchLastModifiedDisabled() {
 	config.LastModifiedEnabled = false
-	data := s.readTestFile("test1.png")
+	data := s.testData.Read("test1.png")
 	lastModified := "Wed, 21 Oct 2015 07:28:00 GMT"
 	ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
-		modifiedSince := r.Header.Get("If-Modified-Since")
+		modifiedSince := r.Header.Get(httpheaders.IfModifiedSince)
 		s.Empty(modifiedSince)
 		rw.WriteHeader(200)
 		rw.Write(data)
@@ -475,9 +413,8 @@ func (s *ProcessingHandlerTestSuite) TestModifiedSinceReqExactMatchLastModifiedD
 	defer ts.Close()
 
 	header := make(http.Header)
-	header.Set("If-Modified-Since", lastModified)
-	rw := s.send(fmt.Sprintf("/unsafe/plain/%s", ts.URL), header)
-	res := rw.Result()
+	header.Set(httpheaders.IfModifiedSince, lastModified)
+	res := s.GET(fmt.Sprintf("/unsafe/plain/%s", ts.URL), header)
 
 	s.Require().Equal(200, res.StatusCode)
 }
@@ -486,25 +423,24 @@ func (s *ProcessingHandlerTestSuite) TestModifiedSinceReqExactMatchLastModifiedE
 	config.LastModifiedEnabled = true
 	lastModified := "Wed, 21 Oct 2015 07:28:00 GMT"
 	ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
-		modifiedSince := r.Header.Get("If-Modified-Since")
+		modifiedSince := r.Header.Get(httpheaders.IfModifiedSince)
 		s.Equal(lastModified, modifiedSince)
 		rw.WriteHeader(304)
 	}))
 	defer ts.Close()
 
 	header := make(http.Header)
-	header.Set("If-Modified-Since", lastModified)
-	rw := s.send(fmt.Sprintf("/unsafe/plain/%s", ts.URL), header)
-	res := rw.Result()
+	header.Set(httpheaders.IfModifiedSince, lastModified)
+	res := s.GET(fmt.Sprintf("/unsafe/plain/%s", ts.URL), header)
 
 	s.Require().Equal(304, res.StatusCode)
 }
 
 func (s *ProcessingHandlerTestSuite) TestModifiedSinceReqCompareMoreRecentLastModifiedDisabled() {
-	data := s.readTestFile("test1.png")
+	data := s.testData.Read("test1.png")
 	config.LastModifiedEnabled = false
 	ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
-		modifiedSince := r.Header.Get("If-Modified-Since")
+		modifiedSince := r.Header.Get(httpheaders.IfModifiedSince)
 		s.Empty(modifiedSince)
 		rw.WriteHeader(200)
 		rw.Write(data)
@@ -514,10 +450,9 @@ func (s *ProcessingHandlerTestSuite) TestModifiedSinceReqCompareMoreRecentLastMo
 	recentTimestamp := "Thu, 25 Feb 2021 01:45:00 GMT"
 
 	header := make(http.Header)
-	header.Set("If-Modified-Since", recentTimestamp)
-	rw := s.send(fmt.Sprintf("/unsafe/plain/%s", ts.URL), header)
-	res := rw.Result()
+	header.Set(httpheaders.IfModifiedSince, recentTimestamp)
 
+	res := s.GET(fmt.Sprintf("/unsafe/plain/%s", ts.URL), header)
 	s.Require().Equal(200, res.StatusCode)
 }
 
@@ -525,7 +460,7 @@ func (s *ProcessingHandlerTestSuite) TestModifiedSinceReqCompareMoreRecentLastMo
 	config.LastModifiedEnabled = true
 	ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
 		fileLastModified, _ := time.Parse(http.TimeFormat, "Wed, 21 Oct 2015 07:28:00 GMT")
-		modifiedSince := r.Header.Get("If-Modified-Since")
+		modifiedSince := r.Header.Get(httpheaders.IfModifiedSince)
 		parsedModifiedSince, err := time.Parse(http.TimeFormat, modifiedSince)
 		s.NoError(err)
 		s.True(fileLastModified.Before(parsedModifiedSince))
@@ -536,18 +471,17 @@ func (s *ProcessingHandlerTestSuite) TestModifiedSinceReqCompareMoreRecentLastMo
 	recentTimestamp := "Thu, 25 Feb 2021 01:45:00 GMT"
 
 	header := make(http.Header)
-	header.Set("If-Modified-Since", recentTimestamp)
-	rw := s.send(fmt.Sprintf("/unsafe/plain/%s", ts.URL), header)
-	res := rw.Result()
+	header.Set(httpheaders.IfModifiedSince, recentTimestamp)
+	res := s.GET(fmt.Sprintf("/unsafe/plain/%s", ts.URL), header)
 
 	s.Require().Equal(304, res.StatusCode)
 }
 
 func (s *ProcessingHandlerTestSuite) TestModifiedSinceReqCompareTooOldLastModifiedDisabled() {
-	config.LastModifiedEnabled = false
-	data := s.readTestFile("test1.png")
+	s.config().ProcessingHandler.LastModifiedEnabled = false
+	data := s.testData.Read("test1.png")
 	ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
-		modifiedSince := r.Header.Get("If-Modified-Since")
+		modifiedSince := r.Header.Get(httpheaders.IfModifiedSince)
 		s.Empty(modifiedSince)
 		rw.WriteHeader(200)
 		rw.Write(data)
@@ -557,19 +491,18 @@ func (s *ProcessingHandlerTestSuite) TestModifiedSinceReqCompareTooOldLastModifi
 	oldTimestamp := "Tue, 01 Oct 2013 17:31:00 GMT"
 
 	header := make(http.Header)
-	header.Set("If-Modified-Since", oldTimestamp)
-	rw := s.send(fmt.Sprintf("/unsafe/plain/%s", ts.URL), header)
-	res := rw.Result()
+	header.Set(httpheaders.IfModifiedSince, oldTimestamp)
+	res := s.GET(fmt.Sprintf("/unsafe/plain/%s", ts.URL), header)
 
 	s.Require().Equal(200, res.StatusCode)
 }
 
 func (s *ProcessingHandlerTestSuite) TestModifiedSinceReqCompareTooOldLastModifiedEnabled() {
 	config.LastModifiedEnabled = true
-	data := s.readTestFile("test1.png")
+	data := s.testData.Read("test1.png")
 	ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
 		fileLastModified, _ := time.Parse(http.TimeFormat, "Wed, 21 Oct 2015 07:28:00 GMT")
-		modifiedSince := r.Header.Get("If-Modified-Since")
+		modifiedSince := r.Header.Get(httpheaders.IfModifiedSince)
 		parsedModifiedSince, err := time.Parse(http.TimeFormat, modifiedSince)
 		s.NoError(err)
 		s.True(fileLastModified.After(parsedModifiedSince))
@@ -581,9 +514,8 @@ func (s *ProcessingHandlerTestSuite) TestModifiedSinceReqCompareTooOldLastModifi
 	oldTimestamp := "Tue, 01 Oct 2013 17:31:00 GMT"
 
 	header := make(http.Header)
-	header.Set("If-Modified-Since", oldTimestamp)
-	rw := s.send(fmt.Sprintf("/unsafe/plain/%s", ts.URL), header)
-	res := rw.Result()
+	header.Set(httpheaders.IfModifiedSince, oldTimestamp)
+	res := s.GET(fmt.Sprintf("/unsafe/plain/%s", ts.URL), header)
 
 	s.Require().Equal(200, res.StatusCode)
 }
@@ -591,43 +523,40 @@ func (s *ProcessingHandlerTestSuite) TestModifiedSinceReqCompareTooOldLastModifi
 func (s *ProcessingHandlerTestSuite) TestAlwaysRasterizeSvg() {
 	config.AlwaysRasterizeSvg = true
 
-	rw := s.send("/unsafe/rs:fill:40:40/plain/local:///test1.svg")
-	res := rw.Result()
+	res := s.GET("/unsafe/rs:fill:40:40/plain/local:///test1.svg")
 
 	s.Require().Equal(200, res.StatusCode)
-	s.Require().Equal("image/png", res.Header.Get("Content-Type"))
+	s.Require().Equal("image/png", res.Header.Get(httpheaders.ContentType))
 }
 
 func (s *ProcessingHandlerTestSuite) TestAlwaysRasterizeSvgWithEnforceAvif() {
 	config.AlwaysRasterizeSvg = true
 	config.EnforceWebp = true
 
-	rw := s.send("/unsafe/plain/local:///test1.svg", http.Header{"Accept": []string{"image/webp"}})
-	res := rw.Result()
+	res := s.GET("/unsafe/plain/local:///test1.svg", http.Header{"Accept": []string{"image/webp"}})
 
 	s.Require().Equal(200, res.StatusCode)
-	s.Require().Equal("image/webp", res.Header.Get("Content-Type"))
+	s.Require().Equal("image/webp", res.Header.Get(httpheaders.ContentType))
 }
 
 func (s *ProcessingHandlerTestSuite) TestAlwaysRasterizeSvgDisabled() {
 	config.AlwaysRasterizeSvg = false
 	config.EnforceWebp = true
 
-	rw := s.send("/unsafe/plain/local:///test1.svg")
-	res := rw.Result()
+	res := s.GET("/unsafe/plain/local:///test1.svg")
 
 	s.Require().Equal(200, res.StatusCode)
-	s.Require().Equal("image/svg+xml", res.Header.Get("Content-Type"))
+	s.Require().Equal("image/svg+xml", res.Header.Get(httpheaders.ContentType))
 }
 
 func (s *ProcessingHandlerTestSuite) TestAlwaysRasterizeSvgWithFormat() {
 	config.AlwaysRasterizeSvg = true
 	config.SkipProcessingFormats = []imagetype.Type{imagetype.SVG}
-	rw := s.send("/unsafe/plain/local:///test1.svg@svg")
-	res := rw.Result()
+
+	res := s.GET("/unsafe/plain/local:///test1.svg@svg")
 
 	s.Require().Equal(200, res.StatusCode)
-	s.Require().Equal("image/svg+xml", res.Header.Get("Content-Type"))
+	s.Require().Equal("image/svg+xml", res.Header.Get(httpheaders.ContentType))
 }
 
 func (s *ProcessingHandlerTestSuite) TestMaxSrcFileSizeGlobal() {
@@ -635,12 +564,11 @@ func (s *ProcessingHandlerTestSuite) TestMaxSrcFileSizeGlobal() {
 
 	ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
 		rw.WriteHeader(200)
-		rw.Write(s.readTestFile("test1.png"))
+		rw.Write(s.testData.Read("test1.png"))
 	}))
 	defer ts.Close()
 
-	rw := s.send("/unsafe/rs:fill:4:4/plain/" + ts.URL)
-	res := rw.Result()
+	res := s.GET("/unsafe/rs:fill:4:4/plain/" + ts.URL)
 
 	s.Require().Equal(422, res.StatusCode)
 }

+ 0 - 137
integration/test_utils.go

@@ -1,137 +0,0 @@
-//go:build integration
-// +build integration
-
-// Integration test helpers for imgproxy.
-// We use regular `go build` instead of Docker to make sure
-// tests run in the same environment as other tests,
-// including in CI, where everything runs in a custom Docker image
-// against the different libvips versions.
-
-package integration
-
-import (
-	"context"
-	"fmt"
-	"io"
-	"net"
-	"net/http"
-	"os"
-	"os/exec"
-	"path"
-	"path/filepath"
-	"testing"
-	"time"
-
-	"github.com/stretchr/testify/require"
-)
-
-const (
-	buildContext = ".."                 // Source code folder
-	binPath      = "/tmp/imgproxy-test" // Path to the built imgproxy binary
-	bindPort     = 9090                 // Port to bind imgproxy to
-	bindHost     = "127.0.0.1"          // Host to bind imgproxy to
-)
-
-var (
-	buildCmd = []string{"build", "-v", "-ldflags=-s -w", "-o", binPath} // imgproxy build command
-)
-
-// waitForPort tries to connect to host:port until successful or timeout
-func waitForPort(host string, port int, timeout time.Duration) error {
-	var address string
-	if net.ParseIP(host) != nil && net.ParseIP(host).To4() == nil {
-		// IPv6 address, wrap in brackets
-		address = fmt.Sprintf("[%s]:%d", host, port)
-	} else {
-		address = fmt.Sprintf("%s:%d", host, port)
-	}
-	deadline := time.Now().Add(timeout)
-
-	for time.Now().Before(deadline) {
-		conn, err := net.DialTimeout("tcp", address, 500*time.Millisecond)
-		if err == nil {
-			conn.Close()
-			return nil // port is open
-		}
-		time.Sleep(200 * time.Millisecond)
-	}
-
-	return fmt.Errorf("timeout waiting for port %s", address)
-}
-
-func startImgproxy(t *testing.T, ctx context.Context, testImagesPath string) string {
-	// Build the imgproxy binary
-	buildCmd := exec.Command("go", buildCmd...)
-	buildCmd.Dir = buildContext
-	buildCmd.Env = os.Environ()
-	buildOut, err := buildCmd.CombinedOutput()
-	require.NoError(t, err, "failed to build imgproxy: %v\n%s", err, string(buildOut))
-
-	// Start imgproxy in the background
-	cmd := exec.CommandContext(ctx, binPath)
-
-	// Set environment variables for imgproxy
-	cmd.Env = append(os.Environ(), "IMGPROXY_BIND=:"+fmt.Sprintf("%d", bindPort))
-	cmd.Env = append(cmd.Env, "IMGPROXY_LOCAL_FILESYSTEM_ROOT="+testImagesPath)
-	cmd.Env = append(cmd.Env, "IMGPROXY_MAX_ANIMATION_FRAMES=999")
-	cmd.Env = append(cmd.Env, "IMGPROXY_VIPS_LEAK_CHECK=true")
-	cmd.Env = append(cmd.Env, "IMGPROXY_LOG_MEM_STATS=true")
-	cmd.Env = append(cmd.Env, "IMGPROXY_DEVELOPMENT_ERRORS_MODE=true")
-
-	// That one is for the build logs
-	stdout, _ := os.CreateTemp("", "imgproxy-stdout-*")
-	stderr, _ := os.CreateTemp("", "imgproxy-stderr-*")
-	cmd.Stdout = stdout
-	cmd.Stderr = stderr
-
-	err = cmd.Start()
-	require.NoError(t, err, "failed to start imgproxy: %v", err)
-
-	// Wait for port 8090 to be available
-	err = waitForPort(bindHost, bindPort, 5*time.Second)
-	if err != nil {
-		cmd.Process.Kill()
-		require.NoError(t, err, "imgproxy did not start in time")
-	}
-
-	// Return a dummy container (nil) and connection string
-	t.Cleanup(func() {
-		cmd.Process.Kill()
-		stdout.Close()
-		stderr.Close()
-		os.Remove(stdout.Name())
-		os.Remove(stderr.Name())
-		os.Remove(binPath)
-	})
-
-	return fmt.Sprintf("%s:%d", bindHost, bindPort)
-}
-
-// fetchImage fetches an image from the imgproxy server
-func fetchImage(t *testing.T, cs string, path string) []byte {
-	url := fmt.Sprintf("http://%s/%s", cs, path)
-
-	resp, err := http.Get(url)
-	require.NoError(t, err, "Failed to fetch image from %s", url)
-	defer resp.Body.Close()
-
-	require.Equal(t, http.StatusOK, resp.StatusCode, "Expected status code 200 OK, got %d, url: %s", resp.StatusCode, url)
-
-	bytes, err := io.ReadAll(resp.Body)
-	require.NoError(t, err, "Failed to read response body from %s", url)
-
-	return bytes
-}
-
-// testImagesPath returns the absolute path to the test images directory
-func testImagesPath(t *testing.T) (string, error) {
-	// Get current working directory
-	dir, err := os.Getwd()
-	require.NoError(t, err)
-
-	// Convert to absolute path (if it's not already)
-	absPath, err := filepath.Abs(dir)
-	require.NoError(t, err)
-
-	return path.Join(absPath, "../testdata/test-images"), nil
-}

+ 0 - 276
main.go

@@ -1,276 +0,0 @@
-package main
-
-import (
-	"context"
-	"flag"
-	"fmt"
-	"net/http"
-	"os"
-	"os/signal"
-	"syscall"
-	"time"
-
-	log "github.com/sirupsen/logrus"
-	"go.uber.org/automaxprocs/maxprocs"
-
-	"github.com/imgproxy/imgproxy/v3/auximageprovider"
-	"github.com/imgproxy/imgproxy/v3/config"
-	"github.com/imgproxy/imgproxy/v3/config/loadenv"
-	"github.com/imgproxy/imgproxy/v3/errorreport"
-	"github.com/imgproxy/imgproxy/v3/fetcher"
-	"github.com/imgproxy/imgproxy/v3/gliblog"
-	"github.com/imgproxy/imgproxy/v3/handlers"
-	processingHandler "github.com/imgproxy/imgproxy/v3/handlers/processing"
-	"github.com/imgproxy/imgproxy/v3/handlers/stream"
-	"github.com/imgproxy/imgproxy/v3/headerwriter"
-	"github.com/imgproxy/imgproxy/v3/ierrors"
-	"github.com/imgproxy/imgproxy/v3/imagedata"
-	"github.com/imgproxy/imgproxy/v3/logger"
-	"github.com/imgproxy/imgproxy/v3/memory"
-	"github.com/imgproxy/imgproxy/v3/monitoring"
-	"github.com/imgproxy/imgproxy/v3/monitoring/prometheus"
-	"github.com/imgproxy/imgproxy/v3/options"
-	"github.com/imgproxy/imgproxy/v3/processing"
-	"github.com/imgproxy/imgproxy/v3/semaphores"
-	"github.com/imgproxy/imgproxy/v3/server"
-	"github.com/imgproxy/imgproxy/v3/transport"
-	"github.com/imgproxy/imgproxy/v3/version"
-	"github.com/imgproxy/imgproxy/v3/vips"
-)
-
-const (
-	faviconPath    = "/favicon.ico"
-	healthPath     = "/health"
-	categoryConfig = "(tmp)config" // NOTE: temporary category for reporting configration errors
-)
-
-func callHandleProcessing(reqID string, rw http.ResponseWriter, r *http.Request) error {
-	// NOTE: This is temporary, will be moved level up at once
-	hwc, err := headerwriter.LoadConfigFromEnv(nil)
-	if err != nil {
-		return ierrors.Wrap(err, 0, ierrors.WithCategory(categoryConfig))
-	}
-
-	hw, err := headerwriter.New(hwc)
-	if err != nil {
-		return ierrors.Wrap(err, 0, ierrors.WithCategory(categoryConfig))
-	}
-
-	sc, err := stream.LoadConfigFromEnv(nil)
-	if err != nil {
-		return ierrors.Wrap(err, 0, ierrors.WithCategory(categoryConfig))
-	}
-
-	tcfg, err := transport.LoadConfigFromEnv(nil)
-	if err != nil {
-		return ierrors.Wrap(err, 0, ierrors.WithCategory(categoryConfig))
-	}
-
-	tr, err := transport.New(tcfg)
-	if err != nil {
-		return ierrors.Wrap(err, 0, ierrors.WithCategory(categoryConfig))
-	}
-
-	fc, err := fetcher.LoadConfigFromEnv(nil)
-	if err != nil {
-		return ierrors.Wrap(err, 0, ierrors.WithCategory(categoryConfig))
-	}
-
-	fetcher, err := fetcher.New(tr, fc)
-	if err != nil {
-		return ierrors.Wrap(err, 0, ierrors.WithCategory(categoryConfig))
-	}
-
-	idf := imagedata.NewFactory(fetcher)
-
-	stream, err := stream.New(sc, hw, fetcher)
-	if err != nil {
-		return ierrors.Wrap(err, 0, ierrors.WithCategory(categoryConfig))
-	}
-
-	phc, err := processingHandler.LoadConfigFromEnv(nil)
-	if err != nil {
-		return ierrors.Wrap(err, 0, ierrors.WithCategory(categoryConfig))
-	}
-
-	semc, err := semaphores.LoadConfigFromEnv(nil)
-	if err != nil {
-		return ierrors.Wrap(err, 0, ierrors.WithCategory(categoryConfig))
-	}
-
-	semaphores, err := semaphores.New(semc)
-	if err != nil {
-		return ierrors.Wrap(err, 0, ierrors.WithCategory(categoryConfig))
-	}
-
-	fic, err := auximageprovider.LoadFallbackStaticConfigFromEnv(nil)
-	if err != nil {
-		return ierrors.Wrap(err, 0, ierrors.WithCategory(categoryConfig))
-	}
-
-	fi, err := auximageprovider.NewStaticProvider(
-		r.Context(),
-		fic,
-		"fallback image",
-		idf,
-	)
-	if err != nil {
-		return ierrors.Wrap(err, 0, ierrors.WithCategory(categoryConfig))
-	}
-
-	wic, err := auximageprovider.LoadWatermarkStaticConfigFromEnv(nil)
-	if err != nil {
-		return ierrors.Wrap(err, 0, ierrors.WithCategory(categoryConfig))
-	}
-
-	wi, err := auximageprovider.NewStaticProvider(
-		r.Context(),
-		wic,
-		"watermark image",
-		idf,
-	)
-	if err != nil {
-		return ierrors.Wrap(err, 0, ierrors.WithCategory(categoryConfig))
-	}
-
-	h, err := processingHandler.New(stream, hw, semaphores, fi, wi, idf, phc)
-	if err != nil {
-		return ierrors.Wrap(err, 0, ierrors.WithCategory(categoryConfig))
-	}
-
-	return h.Execute(reqID, rw, r)
-}
-
-func buildRouter(r *server.Router) *server.Router {
-	r.GET("/", handlers.LandingHandler)
-	r.GET("", handlers.LandingHandler)
-
-	r.GET(faviconPath, r.NotFoundHandler).Silent()
-	r.GET(healthPath, handlers.HealthHandler).Silent()
-	if config.HealthCheckPath != "" {
-		r.GET(config.HealthCheckPath, handlers.HealthHandler).Silent()
-	}
-
-	r.GET(
-		"/*", callHandleProcessing,
-		r.WithSecret, r.WithCORS, r.WithPanic, r.WithReportError, r.WithMonitoring,
-	)
-
-	r.HEAD("/*", r.OkHandler, r.WithCORS)
-	r.OPTIONS("/*", r.OkHandler, r.WithCORS)
-
-	return r
-}
-
-func initialize() error {
-	if err := loadenv.Load(); err != nil {
-		return err
-	}
-
-	if err := logger.Init(); err != nil {
-		return err
-	}
-
-	gliblog.Init()
-
-	maxprocs.Set(maxprocs.Logger(log.Debugf))
-
-	if err := config.Configure(); err != nil {
-		return err
-	}
-
-	if err := monitoring.Init(); err != nil {
-		return err
-	}
-
-	errorreport.Init()
-
-	if err := vips.Init(); err != nil {
-		return err
-	}
-
-	if err := processing.ValidatePreferredFormats(); err != nil {
-		vips.Shutdown()
-		return err
-	}
-
-	if err := options.ParsePresets(config.Presets); err != nil {
-		vips.Shutdown()
-		return err
-	}
-
-	if err := options.ValidatePresets(); err != nil {
-		vips.Shutdown()
-		return err
-	}
-
-	return nil
-}
-
-func shutdown() {
-	monitoring.Stop()
-	errorreport.Close()
-	vips.Shutdown()
-}
-
-func run(ctx context.Context) error {
-	if err := initialize(); err != nil {
-		return err
-	}
-
-	defer shutdown()
-
-	go func() {
-		var logMemStats = len(os.Getenv("IMGPROXY_LOG_MEM_STATS")) > 0
-
-		for range time.Tick(time.Duration(config.FreeMemoryInterval) * time.Second) {
-			memory.Free()
-
-			if logMemStats {
-				memory.LogStats()
-			}
-		}
-	}()
-
-	ctx, cancel := signal.NotifyContext(ctx, syscall.SIGINT, syscall.SIGTERM)
-
-	if err := prometheus.StartServer(cancel); err != nil {
-		return err
-	}
-
-	cfg, err := server.LoadConfigFromEnv(nil)
-	if err != nil {
-		return err
-	}
-
-	r, err := server.NewRouter(cfg)
-	if err != nil {
-		return err
-	}
-
-	s, err := server.Start(cancel, buildRouter(r))
-	if err != nil {
-		return err
-	}
-	defer s.Shutdown(context.Background())
-
-	<-ctx.Done()
-
-	return nil
-}
-
-func main() {
-	flag.Parse()
-
-	switch flag.Arg(0) {
-	case "health":
-		os.Exit(healthcheck())
-	case "version":
-		fmt.Println(version.Version)
-		os.Exit(0)
-	}
-
-	if err := run(context.Background()); err != nil {
-		log.Fatal(err)
-	}
-}

+ 1 - 1
pprof.go

@@ -1,7 +1,7 @@
 //go:build pprof
 // +build pprof
 
-package main
+package imgproxy
 
 import (
 	"net/http"

+ 13 - 0
server/config.go

@@ -3,6 +3,7 @@ package server
 import (
 	"errors"
 	"fmt"
+	"os"
 	"time"
 
 	"github.com/imgproxy/imgproxy/v3/config"
@@ -25,6 +26,10 @@ type Config struct {
 	DevelopmentErrorsMode bool          // Enable development mode for detailed error messages
 	SocketReusePort       bool          // Enable SO_REUSEPORT socket option
 	HealthCheckPath       string        // Health check path from config
+
+	// TODO: We are not sure where to put it yet
+	FreeMemoryInterval time.Duration // Interval for freeing memory
+	LogMemStats        bool          // Log memory stats
 }
 
 // NewDefaultConfig returns default config values
@@ -43,6 +48,8 @@ func NewDefaultConfig() Config {
 		DevelopmentErrorsMode: false,
 		SocketReusePort:       false,
 		HealthCheckPath:       "",
+		FreeMemoryInterval:    10 * time.Second,
+		LogMemStats:           false,
 	}
 }
 
@@ -62,6 +69,8 @@ func LoadConfigFromEnv(c *Config) (*Config, error) {
 	c.DevelopmentErrorsMode = config.DevelopmentErrorsMode
 	c.SocketReusePort = config.SoReuseport
 	c.HealthCheckPath = config.HealthCheckPath
+	c.FreeMemoryInterval = time.Duration(config.FreeMemoryInterval) * time.Second
+	c.LogMemStats = len(os.Getenv("IMGPROXY_LOG_MEM_STATS")) > 0
 
 	return c, nil
 }
@@ -92,5 +101,9 @@ func (c *Config) Validate() error {
 		return fmt.Errorf("graceful timeout should be greater than or equal to 0, now - %d", c.GracefulTimeout)
 	}
 
+	if c.FreeMemoryInterval <= 0 {
+		return errors.New("free memory interval should be greater than zero")
+	}
+
 	return nil
 }

+ 8 - 6
server/router.go

@@ -127,14 +127,16 @@ func (r *Router) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
 	rw.Header().Set(httpheaders.XRequestID, reqID)
 
 	for _, rr := range r.routes {
-		if rr.isMatch(req) {
-			if !rr.silent {
-				LogRequest(reqID, req)
-			}
+		if !rr.isMatch(req) {
+			continue
+		}
 
-			rr.handler(reqID, rw, req)
-			return
+		if !rr.silent {
+			LogRequest(reqID, req)
 		}
+
+		rr.handler(reqID, rw, req)
+		return
 	}
 
 	// Means that we have not found matching route

+ 32 - 0
testutil/lazy_obj.go

@@ -0,0 +1,32 @@
+package testutil
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/require"
+)
+
+// LazyObj is a function that returns an object of type T.
+type LazyObj[T any] func() T
+
+// LazyObjInit is a function that initializes and returns an object of type T and an error if any.
+type LazyObjInit[T any] func() (T, error)
+
+// NewLazyObj creates a new LazyObj that initializes the object on the first call.
+func NewLazyObj[T any](t *testing.T, init LazyObjInit[T]) LazyObj[T] {
+	t.Helper()
+
+	var obj *T
+
+	return func() T {
+		if obj != nil {
+			return *obj
+		}
+
+		o, err := init()
+		require.NoError(t, err)
+
+		obj = &o
+		return o
+	}
+}

+ 5 - 4
testutil/testutil.go → testutil/readers_equal.go

@@ -2,6 +2,7 @@ package testutil
 
 import (
 	"io"
+	"testing"
 
 	"github.com/stretchr/testify/require"
 )
@@ -10,10 +11,10 @@ const bufSize = 4096
 
 // RequireReadersEqual compares two io.Reader contents in a streaming manner.
 // It fails the test if contents differ or if reading fails.
-func ReadersEqual(t require.TestingT, expected, actual io.Reader) bool {
-	if h, ok := t.(interface{ Helper() }); ok {
-		h.Helper()
-	}
+func ReadersEqual(t *testing.T, expected, actual io.Reader) bool {
+	// Marks this function as a test helper so in case failure happens here, location would
+	// point to the correct line in the calling test.
+	t.Helper()
 
 	buf1 := make([]byte, bufSize)
 	buf2 := make([]byte, bufSize)

+ 99 - 0
testutil/test_data_provider.go

@@ -0,0 +1,99 @@
+package testutil
+
+import (
+	"bytes"
+	"io"
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+)
+
+const (
+	// TestDataFolderName is the name of the testdata directory
+	TestDataFolderName = "testdata"
+)
+
+// TestDataProvider provides access to test data images
+type TestDataProvider struct {
+	path string
+	t    *testing.T
+}
+
+// New creates a new TestDataProvider
+func NewTestDataProvider(t *testing.T) *TestDataProvider {
+	// if h, ok := t.(interface{ Helper() }); ok {
+	// 	h.Helper()
+	// }
+	t.Helper()
+
+	path, err := findProjectRoot()
+	if err != nil {
+		require.NoError(t, err)
+	}
+
+	return &TestDataProvider{
+		path: filepath.Join(path, TestDataFolderName),
+		t:    t,
+	}
+}
+
+// findProjectRoot finds the absolute path to the project root by looking for go.mod
+func findProjectRoot() (string, error) {
+	// Start from current working directory
+	wd, err := os.Getwd()
+	if err != nil {
+		return "", err
+	}
+
+	// Walk up the directory tree looking for go.mod
+	dir := wd
+	for {
+		goModPath := filepath.Join(dir, "go.mod")
+		if _, err := os.Stat(goModPath); err == nil {
+			// Found go.mod, this is our project root
+			return dir, nil
+		}
+
+		parent := filepath.Dir(dir)
+		if parent == dir {
+			// Reached filesystem root without finding go.mod
+			break
+		}
+		dir = parent
+	}
+	return "", os.ErrNotExist
+
+}
+
+// Root returns the absolute path to the testdata directory
+func (p *TestDataProvider) Root() string {
+	return p.path
+}
+
+// Path returns the absolute path to a file in the testdata directory
+func (p *TestDataProvider) Path(parts ...string) string {
+	allParts := append([]string{p.path}, parts...)
+	return filepath.Join(allParts...)
+}
+
+// Read reads a test data file and returns it as bytes
+func (p *TestDataProvider) Read(name string) []byte {
+	p.t.Helper()
+
+	data, err := os.ReadFile(p.Path(name))
+	require.NoError(p.t, err)
+	return data
+}
+
+// Data reads a test data file and returns it as imagedata.ImageData
+func (p *TestDataProvider) Reader(name string) *bytes.Reader {
+	return bytes.NewReader(p.Read(name))
+}
+
+// FileEqualsToReader compares the contents of a test data file with the contents of the given reader
+func (p *TestDataProvider) FileEqualsToReader(name string, reader io.Reader) bool {
+	expected := p.Reader(name)
+	return ReadersEqual(p.t, expected, reader)
+}