From c94915b3607fdaedc4c0fc0f7bc8b59d4eaa44ad Mon Sep 17 00:00:00 2001 From: Akshat Singhal Date: Fri, 27 Feb 2026 11:28:42 +0530 Subject: [PATCH 01/14] Add multi-platform Docker image support (amd64 + arm64) Enable native arm64 support for Apple Silicon users by building multi-architecture images via buildx. Uses TARGETARCH for explicit Go cross-compilation. Co-Authored-By: Claude Opus 4.5 --- .github/workflows/go.yaml | 1 + Dockerfile | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/go.yaml b/.github/workflows/go.yaml index ac7802c..850e208 100644 --- a/.github/workflows/go.yaml +++ b/.github/workflows/go.yaml @@ -84,6 +84,7 @@ jobs: push: true context: . file: Dockerfile + platforms: linux/amd64,linux/arm64 provenance: true sbom: true cache-from: type=gha diff --git a/Dockerfile b/Dockerfile index af9b5cd..6b08421 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,7 +9,9 @@ RUN go mod download # Copy source code (this layer changes frequently) COPY . . -RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /app/main main.go + +ARG TARGETARCH +RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build -ldflags="-s -w" -o /app/main main.go # Final stage - distroless FROM gcr.io/distroless/static-debian12 From 24120979ec836aeae61d9533db2e982020c91286 Mon Sep 17 00:00:00 2001 From: Akshat Singhal Date: Fri, 27 Feb 2026 11:29:13 +0530 Subject: [PATCH 02/14] Update README.md with tag v0.0.7 Co-Authored-By: Claude Opus 4.5 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index c418cc2..fe46474 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ To deploy the server, you need to build a Docker image using the provided `Docke ```dockerfile # Use the official static-server image as the base image # This will pull the prebuilt version of the static-server to run your static website -FROM zopdev/static-server:v0.0.6 +FROM zopdev/static-server:v0.0.7 # Copy static files into the container # The 'COPY' directive moves your static files (in this case, located at '/app/out') into the '/website' directory From aa0c39232c03491e1041396f02d6f9a76b84b523 Mon Sep 17 00:00:00 2001 From: Akshat Singhal Date: Fri, 27 Feb 2026 11:37:58 +0530 Subject: [PATCH 03/14] Add QEMU setup for multi-platform Docker builds Required for arm64 emulation on amd64 runners. Co-Authored-By: Claude Opus 4.5 --- .github/workflows/go.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/go.yaml b/.github/workflows/go.yaml index 850e208..3d4896c 100644 --- a/.github/workflows/go.yaml +++ b/.github/workflows/go.yaml @@ -69,6 +69,8 @@ jobs: steps: - uses: actions/checkout@v4 + - uses: docker/setup-qemu-action@v3 + - uses: docker/setup-buildx-action@v3 - uses: docker/login-action@v3 From ecb09f791cace98229aeb06e2bfa9f99a729cc58 Mon Sep 17 00:00:00 2001 From: Akshat Singhal Date: Fri, 27 Feb 2026 11:50:44 +0530 Subject: [PATCH 04/14] Use cross-compilation for multi-platform Docker builds Replace QEMU emulation with native cross-compilation by adding --platform=$BUILDPLATFORM to the build stage. Go cross-compiles to the target architecture natively, eliminating slow emulation. Co-Authored-By: Claude Opus 4.5 --- .github/workflows/go.yaml | 2 -- Dockerfile | 4 ++-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/.github/workflows/go.yaml b/.github/workflows/go.yaml index 3d4896c..850e208 100644 --- a/.github/workflows/go.yaml +++ b/.github/workflows/go.yaml @@ -69,8 +69,6 @@ jobs: steps: - uses: actions/checkout@v4 - - uses: docker/setup-qemu-action@v3 - - uses: docker/setup-buildx-action@v3 - uses: docker/login-action@v3 diff --git a/Dockerfile b/Dockerfile index 6b08421..8f1ed72 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ -# Build stage -FROM golang:1.26 AS build +# Build stage - runs on native platform, cross-compiles to target +FROM --platform=$BUILDPLATFORM golang:1.26 AS build WORKDIR /src From 3449b51b4db54fa74414ba6541a8cb2414556758 Mon Sep 17 00:00:00 2001 From: Akshat Singhal Date: Fri, 27 Feb 2026 12:04:59 +0530 Subject: [PATCH 05/14] Update GitHub Actions to latest versions and add image output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - actions/checkout v4 → v6 - actions/setup-go v4 → v6 (caching now enabled by default) - golangci/golangci-lint-action v8 → v9 - Add step to output pushed image registry path Co-Authored-By: Claude Opus 4.5 --- .github/workflows/go.yaml | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/.github/workflows/go.yaml b/.github/workflows/go.yaml index 850e208..6316773 100644 --- a/.github/workflows/go.yaml +++ b/.github/workflows/go.yaml @@ -12,11 +12,10 @@ jobs: runs-on: ubuntu-latest name: Test and Build steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v4 + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 with: go-version: '1.26' - cache: true - name: Test run: go test ./... @@ -24,12 +23,11 @@ jobs: name: Code Quality🎖️ runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v4 + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 with: go-version: '1.26' - cache: true - - uses: golangci/golangci-lint-action@v8 + - uses: golangci/golangci-lint-action@v9 with: version: v2.10.1 args: --timeout 9m0s @@ -67,7 +65,7 @@ jobs: contents: read packages: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: docker/setup-buildx-action@v3 @@ -91,3 +89,6 @@ jobs: cache-to: type=gha,mode=max tags: | ${{ vars.DOCKER_REGISTRY_TARGET }}:${{ env.RELEASE_VERSION }} + + - name: Output Image Path + run: echo "Image pushed to ${{ vars.DOCKER_REGISTRY_TARGET }}:${{ env.RELEASE_VERSION }}" From 519197faf980d57ccb296e26d5ec3b7dd17d61d7 Mon Sep 17 00:00:00 2001 From: Akshat Singhal Date: Thu, 5 Mar 2026 17:37:57 +0530 Subject: [PATCH 06/14] Added support for env hydration --- README.md | 35 +++++++++++++++++++++- main.go | 40 +++++++++++++++++++++++++ main_test.go | 84 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 158 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index fe46474..9f0e608 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,36 @@ A simple and efficient solution for serving static files. - The server serves files from the `static` directory by default, but you can change this by setting the `STATIC_DIR_PATH` environment variable. - Support all the confgs of the gofr framework - https://gofr.dev +## Config File Hydration + +When the `CONFIG_FILE_PATH` environment variable is set, the server replaces any `${VAR}` placeholders in that file at startup using values from the environment (including `.env` files). The file is rewritten in-place before serving begins. + +This is useful for injecting runtime configuration into static front-end apps without rebuilding them. + +If any placeholders have no matching environment variable, the server still writes the file (substituting empty strings for missing values) and logs an error listing the unresolved variables. + +#### Example + +Given a `config.json` template: + +```json +{ + "clientId": "${GOOGLE_CLIENT_ID}", + "apiUrl": "${API_BASE_URL}" +} +``` + +If `GOOGLE_CLIENT_ID=abc123` and `API_BASE_URL=https://api.example.com` are set, the file becomes: + +```json +{ + "clientId": "abc123", + "apiUrl": "https://api.example.com" +} +``` + +> See the [example Dockerfile](#1-build-a-docker-image) below for how to set `CONFIG_FILE_PATH`. + ## Usage ### 1. Build a Docker image @@ -22,13 +52,16 @@ To deploy the server, you need to build a Docker image using the provided `Docke ```dockerfile # Use the official static-server image as the base image # This will pull the prebuilt version of the static-server to run your static website -FROM zopdev/static-server:v0.0.7 +FROM zopdev/static-server:v0.0.8 # Copy static files into the container # The 'COPY' directive moves your static files (in this case, located at '/app/out') into the '/website' directory # which is where the static server expects to find the files to serve COPY /app/out /static +# Set the path to the config file for environment variable hydration at startup +ENV CONFIG_FILE_PATH=/static/config.json + # Expose the port on which the server will run # By default, the server listens on port 8000, so we expose that port to allow access from outside the container EXPOSE 8000 diff --git a/main.go b/main.go index f510abe..fb7778b 100644 --- a/main.go +++ b/main.go @@ -1,6 +1,7 @@ package main import ( + "fmt" "net/http" "os" "path/filepath" @@ -8,6 +9,7 @@ import ( "strings" "gofr.dev/pkg/gofr" + "gofr.dev/pkg/gofr/config" ) const defaultStaticFilePath = `./static` @@ -15,9 +17,47 @@ const indexHTML = "/index.html" const htmlExtension = ".html" const rootPath = "/" +func hydrateConfig(cfg config.Config) error { + configPath := cfg.Get("CONFIG_FILE_PATH") + if configPath == "" { + return nil + } + + content, err := os.ReadFile(configPath) + if err != nil { + return fmt.Errorf("failed to read config file: %w", err) + } + + // Hydrate with available vars + result := os.Expand(string(content), cfg.Get) + + if err := os.WriteFile(configPath, []byte(result), 0644); err != nil { + return fmt.Errorf("failed to write config file: %w", err) + } + + // Detect vars that were missing (replaced with empty string) + re := regexp.MustCompile(`\$\{(\w+)\}`) + matches := re.FindAllStringSubmatch(string(content), -1) + var missing []string + for _, m := range matches { + if cfg.Get(m[1]) == "" { + missing = append(missing, m[1]) + } + } + if len(missing) > 0 { + return fmt.Errorf("missing config variables: %v", missing) + } + + return nil +} + func main() { app := gofr.New() + if err := hydrateConfig(app.Config); err != nil { + app.Logger().Error(err) + } + staticFilePath := app.Config.GetOrDefault("STATIC_DIR_PATH", defaultStaticFilePath) app.UseMiddleware(func(h http.Handler) http.Handler { diff --git a/main_test.go b/main_test.go index a85a037..82afacb 100644 --- a/main_test.go +++ b/main_test.go @@ -7,8 +7,92 @@ import ( "path/filepath" "testing" "time" + + "gofr.dev/pkg/gofr/config" ) +func TestHydrateConfig(t *testing.T) { + tests := []struct { + name string + template string + vars map[string]string + expected string + wantErr bool + }{ + { + name: "hydrates config in-place", + template: `{"a":"${A}","b":"${B}"}`, + vars: map[string]string{"A": "1", "B": "2"}, + expected: `{"a":"1","b":"2"}`, + wantErr: false, + }, + { + name: "no-op when config path empty", + vars: map[string]string{}, + wantErr: false, + }, + { + name: "extra config vars not in template", + template: `{"a":"${A}"}`, + vars: map[string]string{"A": "1", "EXTRA": "x"}, + expected: `{"a":"1"}`, + wantErr: false, + }, + { + name: "some template vars missing", + template: `{"a":"${A}","b":"${MISSING}"}`, + vars: map[string]string{"A": "1"}, + expected: `{"a":"1","b":""}`, + wantErr: true, + }, + { + name: "all template vars missing", + template: `{"a":"${X}","b":"${Y}"}`, + vars: map[string]string{}, + expected: `{"a":"","b":""}`, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + vars := make(map[string]string) + for k, v := range tt.vars { + vars[k] = v + } + + if tt.template != "" { + dir := t.TempDir() + configFile := filepath.Join(dir, "config.json") + if err := os.WriteFile(configFile, []byte(tt.template), 0644); err != nil { + t.Fatalf("failed to write config: %v", err) + } + vars["CONFIG_FILE_PATH"] = configFile + } + + cfg := config.NewMockConfig(vars) + err := hydrateConfig(cfg) + + if tt.wantErr && err == nil { + t.Fatal("expected error, got nil") + } + if !tt.wantErr && err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if tt.template != "" { + output, err := os.ReadFile(vars["CONFIG_FILE_PATH"]) + if err != nil { + t.Fatalf("failed to read config: %v", err) + } + if string(output) != tt.expected { + t.Errorf("expected %q, got %q", tt.expected, string(output)) + } + } + }) + } +} + func TestServer(t *testing.T) { // Create a temporary directory //nolint:staticcheck // Ignore as we are testing the server From 18fa6b99df84d5984c8e6a6ad157f547c7db100a Mon Sep 17 00:00:00 2001 From: Akshat Singhal Date: Thu, 5 Mar 2026 17:42:25 +0530 Subject: [PATCH 07/14] Updated README.md --- README.md | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 9f0e608..095d8d9 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ A simple and efficient solution for serving static files. - **Lightweight**: Minimal dependencies for optimal performance. - **Configurable**: You can easily configure the server or extend it based on your needs. - The server serves files from the `static` directory by default, but you can change this by setting the `STATIC_DIR_PATH` environment variable. - - Support all the confgs of the gofr framework - https://gofr.dev + - Support all the configs of the gofr framework - https://gofr.dev ## Config File Hydration @@ -55,20 +55,17 @@ To deploy the server, you need to build a Docker image using the provided `Docke FROM zopdev/static-server:v0.0.8 # Copy static files into the container -# The 'COPY' directive moves your static files (in this case, located at '/app/out') into the '/website' directory +# The 'COPY' directive moves your static files (in this case, located at '/app/out') into the '/static' directory # which is where the static server expects to find the files to serve COPY /app/out /static # Set the path to the config file for environment variable hydration at startup ENV CONFIG_FILE_PATH=/static/config.json -# Expose the port on which the server will run -# By default, the server listens on port 8000, so we expose that port to allow access from outside the container -EXPOSE 8000 +# The server listens on port 8000 by default; set HTTP_PORT to change it # Define the command to run the server -# The static server is started with the '/main' binary included in the image, which will start serving -# the files from the '/website' directory on port 8000 +# The static server is started with the '/main' binary included in the image CMD ["/main"] ``` @@ -108,7 +105,7 @@ Your static files will be served, and the root (`/`) will typically display your ## Notes -- The server serves all files in the `website` directory, so make sure to avoid any sensitive files or configuration details in that directory. +- The server serves all files in the `static` directory, so make sure to avoid any sensitive files or configuration details in that directory. ## License From 5c8a53f3909196316bbcba431c9c4d9e0515bae2 Mon Sep 17 00:00:00 2001 From: Akshat Singhal Date: Fri, 6 Mar 2026 14:51:12 +0530 Subject: [PATCH 08/14] Code refactor --- go.mod | 8 +- internal/config/hydrate.go | 65 +++++++++++++++ internal/config/hydrate_test.go | 137 ++++++++++++++++++++++++++++++++ main.go | 48 +++-------- main_test.go | 84 -------------------- 5 files changed, 216 insertions(+), 126 deletions(-) create mode 100644 internal/config/hydrate.go create mode 100644 internal/config/hydrate_test.go diff --git a/go.mod b/go.mod index d165ee2..b9457b2 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,11 @@ module zop.dev/static-server go 1.25.0 -require gofr.dev v1.54.3 +require ( + github.com/stretchr/testify v1.11.1 + go.uber.org/mock v0.6.0 + gofr.dev v1.54.3 +) require ( cloud.google.com/go v0.123.0 // indirect @@ -57,7 +61,6 @@ require ( github.com/redis/go-redis/v9 v9.17.3 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/segmentio/kafka-go v0.4.50 // indirect - github.com/stretchr/testify v1.11.1 // indirect github.com/xdg-go/pbkdf2 v1.0.0 // indirect github.com/xdg-go/scram v1.2.0 // indirect github.com/xdg-go/stringprep v1.0.4 // indirect @@ -76,7 +79,6 @@ require ( go.opentelemetry.io/otel/sdk/metric v1.40.0 // indirect go.opentelemetry.io/otel/trace v1.40.0 // indirect go.opentelemetry.io/proto/otlp v1.9.0 // indirect - go.uber.org/mock v0.6.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect golang.org/x/crypto v0.48.0 // indirect golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect diff --git a/internal/config/hydrate.go b/internal/config/hydrate.go new file mode 100644 index 0000000..c41b523 --- /dev/null +++ b/internal/config/hydrate.go @@ -0,0 +1,65 @@ +package config + +import ( + "errors" + "fmt" + "io" + "os" + "path/filepath" + "regexp" + + "gofr.dev/pkg/gofr/config" + "gofr.dev/pkg/gofr/datasource/file" +) + +var ( + ErrMissingVars = errors.New("missing config variables") + ErrReadConfig = errors.New("failed to read config file") + ErrWriteConfig = errors.New("failed to write config file") + ErrMissingFile = errors.New("file not found") + + envVarRe = regexp.MustCompile(`\$\{(\w+)\}`) +) + +const filePathVar = "CONFIG_FILE_PATH" + +func HydrateFile(fs file.FileSystem, cfg config.Config) error { + configPath := cfg.Get(filePathVar) + if configPath == "" { + return nil + } + + configFile, err := fs.Open(filepath.Clean(configPath)) + if err != nil { + return fmt.Errorf("%w: %w", ErrReadConfig, err) + } + + content, err := io.ReadAll(configFile) + if err != nil { + return fmt.Errorf("%w: %w", ErrReadConfig, err) + } + + // Hydrate with available vars + result := os.Expand(string(content), cfg.Get) + + if _, err = configFile.Write([]byte(result)); err != nil { + return fmt.Errorf("%w: %w", ErrWriteConfig, err) + } + + // Detect vars that were missing (replaced with empty string) + matches := envVarRe.FindAllStringSubmatch(string(content), -1) + + var missing []string + + for _, m := range matches { + if cfg.Get(m[1]) == "" { + missing = append(missing, m[1]) + } + } + + if len(missing) > 0 { + return fmt.Errorf("%w: %v", ErrMissingVars, missing) + } + + return nil +} diff --git a/internal/config/hydrate_test.go b/internal/config/hydrate_test.go new file mode 100644 index 0000000..1d8945b --- /dev/null +++ b/internal/config/hydrate_test.go @@ -0,0 +1,137 @@ +package config + +import ( + "errors" + "io" + "testing" + + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + "gofr.dev/pkg/gofr/config" + "gofr.dev/pkg/gofr/datasource/file" +) + +var ( + errDiskFailure = errors.New("disk failure") + errDiskFull = errors.New("disk full") +) + +func TestConfig(t *testing.T) { + tests := []struct { + name string + template string + vars map[string]string + expected string + wantErr error + }{ + { + name: "all vars present", + template: `{"a":"${A}","b":"${B}"}`, + vars: map[string]string{"A": "1", "B": "2"}, + expected: `{"a":"1","b":"2"}`, + }, + { + name: "no config path is a no-op", + vars: map[string]string{}, + wantErr: nil, + }, + { + name: "extra vars ignored", + template: `{"a":"${A}"}`, + vars: map[string]string{"A": "1", "EXTRA": "x"}, + expected: `{"a":"1"}`, + }, + { + name: "partial vars missing", + template: `{"a":"${A}","b":"${MISSING}"}`, + vars: map[string]string{"A": "1"}, + expected: `{"a":"1","b":""}`, + wantErr: ErrMissingVars, + }, + { + name: "all vars missing", + template: `{"a":"${X}","b":"${Y}"}`, + vars: map[string]string{}, + expected: `{"a":"","b":""}`, + wantErr: ErrMissingVars, + }, + { + name: "invalid config path", + vars: map[string]string{"CONFIG_FILE_PATH": "/no/such/file"}, + wantErr: ErrReadConfig, + }, + { + name: "read error", + template: "read-error", + vars: map[string]string{}, + wantErr: ErrReadConfig, + }, + { + name: "write error", + template: "write-error", + vars: map[string]string{}, + wantErr: ErrWriteConfig, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + mockFS := file.NewMockFileSystem(ctrl) + + gotOutput := setupMocks(t, ctrl, mockFS, tt.template, tt.vars) + + err := HydrateFile(mockFS, config.NewMockConfig(tt.vars)) + + require.ErrorIs(t, err, tt.wantErr) + require.Equal(t, tt.expected, string(gotOutput())) + }) + } +} + +func setupMocks( + t *testing.T, ctrl *gomock.Controller, mockFS *file.MockFileSystem, + template string, vars map[string]string, +) func() []byte { + t.Helper() + + var output []byte + + switch { + case template != "": + vars[filePathVar] = "/mock/config.json" + mockFile := file.NewMockFile(ctrl) + mockFS.EXPECT().Open(vars[filePathVar]).Return(mockFile, nil) + + setupReadWrite(mockFile, template, &output) + + case vars[filePathVar] != "": + mockFS.EXPECT().Open(vars[filePathVar]).Return(nil, ErrMissingFile) + } + + return func() []byte { return output } +} + +func setupReadWrite(mockFile *file.MockFile, template string, output *[]byte) { + if template == "read-error" { + mockFile.EXPECT().Read(gomock.Any()).Return(0, errDiskFailure) + return + } + + templateBytes := []byte(template) + + mockFile.EXPECT().Read(gomock.Any()).DoAndReturn(func(p []byte) (int, error) { + n := copy(p, templateBytes) + return n, io.EOF + }) + + if template == "write-error" { + mockFile.EXPECT().Write(gomock.Any()).Return(0, errDiskFull) + return + } + + mockFile.EXPECT().Write(gomock.Any()).DoAndReturn(func(p []byte) (int, error) { + *output = append([]byte{}, p...) + return len(p), nil + }) +} diff --git a/main.go b/main.go index fb7778b..249a62c 100644 --- a/main.go +++ b/main.go @@ -1,7 +1,6 @@ package main import ( - "fmt" "net/http" "os" "path/filepath" @@ -9,7 +8,8 @@ import ( "strings" "gofr.dev/pkg/gofr" - "gofr.dev/pkg/gofr/config" + + "zop.dev/static-server/internal/config" ) const defaultStaticFilePath = `./static` @@ -17,46 +17,16 @@ const indexHTML = "/index.html" const htmlExtension = ".html" const rootPath = "/" -func hydrateConfig(cfg config.Config) error { - configPath := cfg.Get("CONFIG_FILE_PATH") - if configPath == "" { - return nil - } - - content, err := os.ReadFile(configPath) - if err != nil { - return fmt.Errorf("failed to read config file: %w", err) - } - - // Hydrate with available vars - result := os.Expand(string(content), cfg.Get) - - if err := os.WriteFile(configPath, []byte(result), 0644); err != nil { - return fmt.Errorf("failed to write config file: %w", err) - } - - // Detect vars that were missing (replaced with empty string) - re := regexp.MustCompile(`\$\{(\w+)\}`) - matches := re.FindAllStringSubmatch(string(content), -1) - var missing []string - for _, m := range matches { - if cfg.Get(m[1]) == "" { - missing = append(missing, m[1]) - } - } - if len(missing) > 0 { - return fmt.Errorf("missing config variables: %v", missing) - } - - return nil -} - func main() { app := gofr.New() - if err := hydrateConfig(app.Config); err != nil { - app.Logger().Error(err) - } + app.OnStart(func(ctx *gofr.Context) error { + if err := config.HydrateFile(ctx.File, app.Config); err != nil { + ctx.Logger.Error(err) + } + + return nil + }) staticFilePath := app.Config.GetOrDefault("STATIC_DIR_PATH", defaultStaticFilePath) diff --git a/main_test.go b/main_test.go index 82afacb..a85a037 100644 --- a/main_test.go +++ b/main_test.go @@ -7,92 +7,8 @@ import ( "path/filepath" "testing" "time" - - "gofr.dev/pkg/gofr/config" ) -func TestHydrateConfig(t *testing.T) { - tests := []struct { - name string - template string - vars map[string]string - expected string - wantErr bool - }{ - { - name: "hydrates config in-place", - template: `{"a":"${A}","b":"${B}"}`, - vars: map[string]string{"A": "1", "B": "2"}, - expected: `{"a":"1","b":"2"}`, - wantErr: false, - }, - { - name: "no-op when config path empty", - vars: map[string]string{}, - wantErr: false, - }, - { - name: "extra config vars not in template", - template: `{"a":"${A}"}`, - vars: map[string]string{"A": "1", "EXTRA": "x"}, - expected: `{"a":"1"}`, - wantErr: false, - }, - { - name: "some template vars missing", - template: `{"a":"${A}","b":"${MISSING}"}`, - vars: map[string]string{"A": "1"}, - expected: `{"a":"1","b":""}`, - wantErr: true, - }, - { - name: "all template vars missing", - template: `{"a":"${X}","b":"${Y}"}`, - vars: map[string]string{}, - expected: `{"a":"","b":""}`, - wantErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - vars := make(map[string]string) - for k, v := range tt.vars { - vars[k] = v - } - - if tt.template != "" { - dir := t.TempDir() - configFile := filepath.Join(dir, "config.json") - if err := os.WriteFile(configFile, []byte(tt.template), 0644); err != nil { - t.Fatalf("failed to write config: %v", err) - } - vars["CONFIG_FILE_PATH"] = configFile - } - - cfg := config.NewMockConfig(vars) - err := hydrateConfig(cfg) - - if tt.wantErr && err == nil { - t.Fatal("expected error, got nil") - } - if !tt.wantErr && err != nil { - t.Fatalf("unexpected error: %v", err) - } - - if tt.template != "" { - output, err := os.ReadFile(vars["CONFIG_FILE_PATH"]) - if err != nil { - t.Fatalf("failed to read config: %v", err) - } - if string(output) != tt.expected { - t.Errorf("expected %q, got %q", tt.expected, string(output)) - } - } - }) - } -} - func TestServer(t *testing.T) { // Create a temporary directory //nolint:staticcheck // Ignore as we are testing the server From fb75fc45f79df9932c2a4e4621eb77a48932576b Mon Sep 17 00:00:00 2001 From: Akshat Singhal Date: Fri, 6 Mar 2026 16:20:28 +0530 Subject: [PATCH 09/14] Fixed implementation --- internal/config/hydrate.go | 9 ++- internal/config/hydrate_test.go | 102 ++++++++++++-------------------- 2 files changed, 47 insertions(+), 64 deletions(-) diff --git a/internal/config/hydrate.go b/internal/config/hydrate.go index c41b523..fa826af 100644 --- a/internal/config/hydrate.go +++ b/internal/config/hydrate.go @@ -39,10 +39,17 @@ func HydrateFile(fs file.FileSystem, cfg config.Config) error { return fmt.Errorf("%w: %w", ErrReadConfig, err) } + configFile.Close() + // Hydrate with available vars result := os.Expand(string(content), cfg.Get) - if _, err = configFile.Write([]byte(result)); err != nil { + wf, err := fs.OpenFile(configPath, os.O_WRONLY|os.O_TRUNC, 0) + if err != nil { + return fmt.Errorf("%w: %w", ErrWriteConfig, err) + } + + if _, err = wf.Write([]byte(result)); err != nil { return fmt.Errorf("%w: %w", ErrWriteConfig, err) } diff --git a/internal/config/hydrate_test.go b/internal/config/hydrate_test.go index 1d8945b..784cd5d 100644 --- a/internal/config/hydrate_test.go +++ b/internal/config/hydrate_test.go @@ -1,20 +1,28 @@ package config import ( - "errors" - "io" + "os" + "path/filepath" + "runtime" "testing" "github.com/stretchr/testify/require" - "go.uber.org/mock/gomock" "gofr.dev/pkg/gofr/config" "gofr.dev/pkg/gofr/datasource/file" + "gofr.dev/pkg/gofr/logging" ) -var ( - errDiskFailure = errors.New("disk failure") - errDiskFull = errors.New("disk full") -) +func writeTempFile(t *testing.T, content string) string { + t.Helper() + + dir := t.TempDir() + path := filepath.Join(dir, "config.json") + + err := os.WriteFile(path, []byte(content), 0644) + require.NoError(t, err) + + return path +} func TestConfig(t *testing.T) { tests := []struct { @@ -60,78 +68,46 @@ func TestConfig(t *testing.T) { vars: map[string]string{"CONFIG_FILE_PATH": "/no/such/file"}, wantErr: ErrReadConfig, }, - { - name: "read error", - template: "read-error", - vars: map[string]string{}, - wantErr: ErrReadConfig, - }, - { - name: "write error", - template: "write-error", - vars: map[string]string{}, - wantErr: ErrWriteConfig, - }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - ctrl := gomock.NewController(t) - mockFS := file.NewMockFileSystem(ctrl) + fs := file.New(logging.NewMockLogger(logging.ERROR)) - gotOutput := setupMocks(t, ctrl, mockFS, tt.template, tt.vars) + if tt.template != "" { + path := writeTempFile(t, tt.template) + tt.vars[filePathVar] = path + } - err := HydrateFile(mockFS, config.NewMockConfig(tt.vars)) + err := HydrateFile(fs, config.NewMockConfig(tt.vars)) require.ErrorIs(t, err, tt.wantErr) - require.Equal(t, tt.expected, string(gotOutput())) - }) - } -} - -func setupMocks( - t *testing.T, ctrl *gomock.Controller, mockFS *file.MockFileSystem, - template string, vars map[string]string, -) func() []byte { - t.Helper() - var output []byte - - switch { - case template != "": - vars[filePathVar] = "/mock/config.json" - mockFile := file.NewMockFile(ctrl) - mockFS.EXPECT().Open(vars[filePathVar]).Return(mockFile, nil) - - setupReadWrite(mockFile, template, &output) - - case vars[filePathVar] != "": - mockFS.EXPECT().Open(vars[filePathVar]).Return(nil, ErrMissingFile) + if tt.expected != "" { + got, readErr := os.ReadFile(tt.vars[filePathVar]) + require.NoError(t, readErr) + require.Equal(t, tt.expected, string(got)) + } + }) } - - return func() []byte { return output } } -func setupReadWrite(mockFile *file.MockFile, template string, output *[]byte) { - if template == "read-error" { - mockFile.EXPECT().Read(gomock.Any()).Return(0, errDiskFailure) - return +func TestHydrateFile_WriteError(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("chmod not effective on Windows") } - templateBytes := []byte(template) + path := writeTempFile(t, `{"a":"${A}"}`) - mockFile.EXPECT().Read(gomock.Any()).DoAndReturn(func(p []byte) (int, error) { - n := copy(p, templateBytes) - return n, io.EOF - }) + err := os.Chmod(path, 0444) + require.NoError(t, err) - if template == "write-error" { - mockFile.EXPECT().Write(gomock.Any()).Return(0, errDiskFull) - return + fs := file.New(logging.NewMockLogger(logging.ERROR)) + vars := map[string]string{ + filePathVar: path, + "A": "1", } - mockFile.EXPECT().Write(gomock.Any()).DoAndReturn(func(p []byte) (int, error) { - *output = append([]byte{}, p...) - return len(p), nil - }) + err = HydrateFile(fs, config.NewMockConfig(vars)) + require.ErrorIs(t, err, ErrWriteConfig) } From d54c4e368c13c21e9a4b702805b060a56643d338 Mon Sep 17 00:00:00 2001 From: Akshat Singhal Date: Fri, 6 Mar 2026 16:34:46 +0530 Subject: [PATCH 10/14] Fixed linter issues --- internal/config/hydrate.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/config/hydrate.go b/internal/config/hydrate.go index fa826af..2af836b 100644 --- a/internal/config/hydrate.go +++ b/internal/config/hydrate.go @@ -39,7 +39,7 @@ func HydrateFile(fs file.FileSystem, cfg config.Config) error { return fmt.Errorf("%w: %w", ErrReadConfig, err) } - configFile.Close() + _ = configFile.Close() // Hydrate with available vars result := os.Expand(string(content), cfg.Get) From 8f96c8e8c17c405e437f840666527117ca5c091b Mon Sep 17 00:00:00 2001 From: Akshat Singhal Date: Fri, 6 Mar 2026 17:00:33 +0530 Subject: [PATCH 11/14] Converted fields to unexported --- internal/config/hydrate.go | 17 ++++++++--------- internal/config/hydrate_test.go | 8 ++++---- 2 files changed, 12 insertions(+), 13 deletions(-) diff --git a/internal/config/hydrate.go b/internal/config/hydrate.go index 2af836b..4f62518 100644 --- a/internal/config/hydrate.go +++ b/internal/config/hydrate.go @@ -13,10 +13,9 @@ import ( ) var ( - ErrMissingVars = errors.New("missing config variables") - ErrReadConfig = errors.New("failed to read config file") - ErrWriteConfig = errors.New("failed to write config file") - ErrMissingFile = errors.New("file not found") + errMissingVars = errors.New("missing config variables") + errReadConfig = errors.New("failed to read config file") + errWriteConfig = errors.New("failed to write config file") envVarRe = regexp.MustCompile(`\$\{(\w+)\}`) ) @@ -31,12 +30,12 @@ func HydrateFile(fs file.FileSystem, cfg config.Config) error { configFile, err := fs.Open(filepath.Clean(configPath)) if err != nil { - return fmt.Errorf("%w: %w", ErrReadConfig, err) + return fmt.Errorf("%w: %w", errReadConfig, err) } content, err := io.ReadAll(configFile) if err != nil { - return fmt.Errorf("%w: %w", ErrReadConfig, err) + return fmt.Errorf("%w: %w", errReadConfig, err) } _ = configFile.Close() @@ -46,11 +45,11 @@ func HydrateFile(fs file.FileSystem, cfg config.Config) error { wf, err := fs.OpenFile(configPath, os.O_WRONLY|os.O_TRUNC, 0) if err != nil { - return fmt.Errorf("%w: %w", ErrWriteConfig, err) + return fmt.Errorf("%w: %w", errWriteConfig, err) } if _, err = wf.Write([]byte(result)); err != nil { - return fmt.Errorf("%w: %w", ErrWriteConfig, err) + return fmt.Errorf("%w: %w", errWriteConfig, err) } // Detect vars that were missing (replaced with empty string) @@ -65,7 +64,7 @@ func HydrateFile(fs file.FileSystem, cfg config.Config) error { } if len(missing) > 0 { - return fmt.Errorf("%w: %v", ErrMissingVars, missing) + return fmt.Errorf("%w: %v", errMissingVars, missing) } return nil diff --git a/internal/config/hydrate_test.go b/internal/config/hydrate_test.go index 784cd5d..743ba07 100644 --- a/internal/config/hydrate_test.go +++ b/internal/config/hydrate_test.go @@ -54,19 +54,19 @@ func TestConfig(t *testing.T) { template: `{"a":"${A}","b":"${MISSING}"}`, vars: map[string]string{"A": "1"}, expected: `{"a":"1","b":""}`, - wantErr: ErrMissingVars, + wantErr: errMissingVars, }, { name: "all vars missing", template: `{"a":"${X}","b":"${Y}"}`, vars: map[string]string{}, expected: `{"a":"","b":""}`, - wantErr: ErrMissingVars, + wantErr: errMissingVars, }, { name: "invalid config path", vars: map[string]string{"CONFIG_FILE_PATH": "/no/such/file"}, - wantErr: ErrReadConfig, + wantErr: errReadConfig, }, } @@ -109,5 +109,5 @@ func TestHydrateFile_WriteError(t *testing.T) { } err = HydrateFile(fs, config.NewMockConfig(vars)) - require.ErrorIs(t, err, ErrWriteConfig) + require.ErrorIs(t, err, errWriteConfig) } From 46b3f7a3343609b5859caac4b3629afa1017de9d Mon Sep 17 00:00:00 2001 From: Akshat Singhal Date: Sat, 7 Mar 2026 14:25:06 +0530 Subject: [PATCH 12/14] Updated tests --- internal/config/hydrate_test.go | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/internal/config/hydrate_test.go b/internal/config/hydrate_test.go index 743ba07..81fb99a 100644 --- a/internal/config/hydrate_test.go +++ b/internal/config/hydrate_test.go @@ -1,6 +1,7 @@ package config import ( + "io" "os" "path/filepath" "runtime" @@ -12,15 +13,20 @@ import ( "gofr.dev/pkg/gofr/logging" ) -func writeTempFile(t *testing.T, content string) string { +func writeTempFile(t *testing.T, fs file.FileSystem, content string) string { t.Helper() dir := t.TempDir() path := filepath.Join(dir, "config.json") - err := os.WriteFile(path, []byte(content), 0644) + f, err := fs.Create(path) require.NoError(t, err) + _, err = f.Write([]byte(content)) + require.NoError(t, err) + + require.NoError(t, f.Close()) + return path } @@ -75,7 +81,7 @@ func TestConfig(t *testing.T) { fs := file.New(logging.NewMockLogger(logging.ERROR)) if tt.template != "" { - path := writeTempFile(t, tt.template) + path := writeTempFile(t, fs, tt.template) tt.vars[filePathVar] = path } @@ -84,7 +90,9 @@ func TestConfig(t *testing.T) { require.ErrorIs(t, err, tt.wantErr) if tt.expected != "" { - got, readErr := os.ReadFile(tt.vars[filePathVar]) + rf, readErr := fs.Open(tt.vars[filePathVar]) + require.NoError(t, readErr) + got, readErr := io.ReadAll(rf) require.NoError(t, readErr) require.Equal(t, tt.expected, string(got)) } @@ -97,12 +105,11 @@ func TestHydrateFile_WriteError(t *testing.T) { t.Skip("chmod not effective on Windows") } - path := writeTempFile(t, `{"a":"${A}"}`) + fs := file.New(logging.NewMockLogger(logging.ERROR)) + path := writeTempFile(t, fs, `{"a":"${A}"}`) err := os.Chmod(path, 0444) require.NoError(t, err) - - fs := file.New(logging.NewMockLogger(logging.ERROR)) vars := map[string]string{ filePathVar: path, "A": "1", From ff25bad4c7c3d2950f24ad25687c0fb1c2ae63fb Mon Sep 17 00:00:00 2001 From: Akshat Singhal Date: Sat, 7 Mar 2026 14:40:50 +0530 Subject: [PATCH 13/14] Fixed linter issue --- internal/config/hydrate_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/config/hydrate_test.go b/internal/config/hydrate_test.go index 81fb99a..613e3f2 100644 --- a/internal/config/hydrate_test.go +++ b/internal/config/hydrate_test.go @@ -110,6 +110,7 @@ func TestHydrateFile_WriteError(t *testing.T) { err := os.Chmod(path, 0444) require.NoError(t, err) + vars := map[string]string{ filePathVar: path, "A": "1", From 43c00efa5abd852568d787914df8620faa494d25 Mon Sep 17 00:00:00 2001 From: Akshat Singhal Date: Mon, 9 Mar 2026 14:38:50 +0530 Subject: [PATCH 14/14] Added go doc for HydrateFile --- internal/config/hydrate.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/internal/config/hydrate.go b/internal/config/hydrate.go index 4f62518..69fb91e 100644 --- a/internal/config/hydrate.go +++ b/internal/config/hydrate.go @@ -22,6 +22,18 @@ var ( const filePathVar = "CONFIG_FILE_PATH" +// HydrateFile reads the file at the path specified by the CONFIG_FILE_PATH config +// variable, substitutes every ${VAR} placeholder with the corresponding value +// obtained from cfg, and writes the result back to the same file. +// +// If CONFIG_FILE_PATH is not set (empty string), HydrateFile is a no-op and +// returns nil. If any referenced variable is not present in the config, an +// errMissingVars error is returned after the file has been written. +// +// cfg is accepted as a config.Config interface rather than as individual values +// so that the set of variables resolved is open-ended: the file may reference +// any variable, and HydrateFile resolves each one dynamically via cfg.Get +// without the caller needing to know which variables the file contains. func HydrateFile(fs file.FileSystem, cfg config.Config) error { configPath := cfg.Get(filePathVar) if configPath == "" {